Clear workspace

This commit is contained in:
Adrian Woźniak 2023-09-04 12:59:23 +02:00
parent 6d721886d0
commit 0b145bec5d
12 changed files with 6 additions and 2751 deletions

43
Cargo.lock generated
View File

@ -40,7 +40,7 @@ dependencies = [
"actix-admin-macros",
"actix-files",
"actix-multipart",
"actix-session 0.7.2",
"actix-session",
"actix-web",
"async-trait",
"chrono",
@ -159,26 +159,11 @@ dependencies = [
"zstd",
]
[[package]]
name = "actix-jwt-authc"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5e2d7e61895ae7e33b8ee818d99e1fe1edd3bf0be5fa04331b66f31dc6e9fe"
dependencies = [
"actix-session 0.6.2",
"actix-web",
"derive_more",
"futures-util",
"jsonwebtoken",
"serde",
"time 0.3.28",
"tokio 1.30.0",
"tracing",
]
[[package]]
name = "actix-jwt-session"
version = "1.0.1"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57613443370ddec840ba877ee18e955c6c4d3972342ef240f918ba520508f28"
dependencies = [
"actix-web",
"argon2",
@ -188,7 +173,6 @@ dependencies = [
"futures",
"futures-lite",
"futures-util",
"garde",
"jsonwebtoken",
"rand 0.8.5",
"redis",
@ -305,24 +289,6 @@ dependencies = [
"pin-project-lite 0.2.12",
]
[[package]]
name = "actix-session"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9138a66462f1e65da829f9c0de81b44a96dfe193a4f19bfea32ee2be312368"
dependencies = [
"actix-service",
"actix-utils",
"actix-web",
"anyhow",
"async-trait",
"derive_more",
"serde",
"serde_json",
"time 0.3.28",
"tracing",
]
[[package]]
name = "actix-session"
version = "0.7.2"
@ -2628,7 +2594,6 @@ name = "oswilno-session"
version = "0.1.0"
dependencies = [
"actix-http",
"actix-jwt-authc",
"actix-jwt-session",
"actix-web",
"argon2",

View File

@ -6,6 +6,5 @@ members = [
'./crates/oswilno-parking-space',
'./crates/migration',
'./crates/oswilno-actix-admin',
'./crates/actix-jwt-session',
]
resolver = "2"

View File

@ -1,44 +0,0 @@
[package]
name = "actix-jwt-session"
version = "1.0.1"
edition = "2021"
description = "Full featured JWT session managment for actix"
license = "MIT"
[features]
default = ['use-redis', 'use-tracing', 'panic-bad-ttl', 'hashing']
use-redis = ["redis", "redis-async-pool"]
use-tracing = ['tracing']
override-bad-ttl = []
panic-bad-ttl = []
hashing = ["argon2"]
[dependencies]
actix-web = "4"
async-trait = "0.1.72"
bincode = "1.3.3"
futures = "0.3.28"
futures-lite = "1.13.0"
futures-util = { version = "0.3.28", features = ['async-await'] }
jsonwebtoken = "8.3.0"
rand = "0.8.5"
redis = { version = "0.17", optional = true }
redis-async-pool = { version = "0.2.4", optional = true }
ring = "0.16.20"
serde = { version = "1.0.183", features = ["derive"] }
serde_json = "1.0.105"
thiserror = "1.0.44"
tokio = { version = "1.30.0", features = ["full"] }
tracing = { version = "0.1.37", optional = true }
uuid = { version = "1.4.1", features = ["v4", "serde"] }
argon2 = { version = "0.5.1", optional = true }
cookie = "0.17.0"
time = { version = "0.3.28", features = ["serde"] }
[[test]]
name = "ensure_redis_flow"
path = "./tests/ensure_redis_flow.rs"
[dev-dependencies]
garde = "0.14.0"
ring = "0.16.20"

View File

@ -1,394 +0,0 @@
![docs.rs](https://img.shields.io/docsrs/actix-jwt-session)
All in one creating session and session validation library for actix.
It's designed to extract session using middleware and validate endpoint simply by using actix-web extractors.
Currently you can extract tokens from Header or Cookie. It's possible to implement Path, Query
or Body using `[ServiceRequest::extract]` but you must have struct to which values will be
extracted so it's easy to do if you have your own fields.
Example:
```rust
use serde::Deserialize;
#[derive(Deserialize)]
struct MyJsonBody {
jwt: Option<String>,
refresh: Option<String>,
}
```
To start with this library you need to create your own `AppClaims` structure and implement
`actix_jwt_session::Claims` trait for it.
```rust
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum Audience {
Web,
}
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)]
#[serde(rename_all = "snake_case")]
pub struct Claims {
#[serde(rename = "exp")]
pub expiration_time: u64,
#[serde(rename = "iat")]
pub issues_at: usize,
/// Account login
#[serde(rename = "sub")]
pub subject: String,
#[serde(rename = "aud")]
pub audience: Audience,
#[serde(rename = "jti")]
pub jwt_id: uuid::Uuid,
#[serde(rename = "aci")]
pub account_id: i32,
#[serde(rename = "nbf")]
pub not_before: u64,
}
impl actix_jwt_session::Claims for Claims {
fn jti(&self) -> uuid::Uuid {
self.jwt_id
}
fn subject(&self) -> &str {
&self.subject
}
}
impl Claims {
pub fn account_id(&self) -> i32 {
self.account_id
}
}
```
Then you must create middleware factory with session storage. Currently there's adapter only
for redis so we will goes with it in this example.
* First create connection pool to redis using `redis_async_pool`.
* Next generate or load create jwt signing keys. They are required for creating JWT from
claims.
* Finally pass keys and algorithm to builder, pass pool and add some extractors
```rust
use std::sync::Arc;
use actix_jwt_session::*;
async fn create<AppClaims: actix_jwt_session::Claims>() {
// create redis connection
let redis = {
use redis_async_pool::{RedisConnectionManager, RedisPool};
RedisPool::new(
RedisConnectionManager::new(
redis::Client::open("redis://localhost:6379").expect("Fail to connect to redis"),
true,
None,
),
5,
)
};
// load or create new keys in `./config`
let keys = JwtSigningKeys::load_or_create();
// create new [SessionStorage] and [SessionMiddlewareFactory]
let (storage, factory) = SessionMiddlewareFactory::<AppClaims>::build(
Arc::new(keys.encoding_key),
Arc::new(keys.decoding_key),
Algorithm::EdDSA
)
// pass redis connection
.with_redis_pool(redis.clone())
// Check if header "Authorization" exists and contains Bearer with encoded JWT
.with_jwt_header("Authorization")
// Check if cookie "jwt" exists and contains encoded JWT
.with_jwt_cookie("acx-a")
.with_refresh_header("ACX-Refresh")
// Check if cookie "jwt" exists and contains encoded JWT
.with_refresh_cookie("acx-r")
.finish();
}
```
As you can see we have there [SessionMiddlewareBuilder::with_refresh_cookie] and [SessionMiddlewareBuilder::with_refresh_header]. Library uses
internal structure [RefreshToken] which is created and managed internally without any additional user work.
This will be used to extend JWT lifetime. This lifetime comes from 2 structures which describe
time to live. [JwtTtl] describes how long access token should be valid, [RefreshToken]
describes how long refresh token is valid. [SessionStorage] allows to extend livetime of both
with single call of [SessionStorage::refresh] and it will change time of creating tokens to
current time.
```rust
use actix_jwt_session::{JwtTtl, RefreshTtl, Duration};
fn example_ttl() {
let jwt_ttl = JwtTtl(Duration::days(14));
let refresh_ttl = RefreshTtl(Duration::days(3 * 31));
}
```
Now you just need to add those structures to [actix_web::App] using `.app_data` and `.wrap` and
you are ready to go. Bellow you have full example of usage.
Examples usage:
```rust
use std::sync::Arc;
use actix_jwt_session::*;
use actix_web::{get, post};
use actix_web::web::{Data, Json};
use actix_web::{HttpResponse, App, HttpServer};
use jsonwebtoken::*;
use serde::{Serialize, Deserialize};
#[tokio::main]
async fn main() {
let redis = {
use redis_async_pool::{RedisConnectionManager, RedisPool};
RedisPool::new(
RedisConnectionManager::new(
redis::Client::open("redis://localhost:6379").expect("Fail to connect to redis"),
true,
None,
),
5,
)
};
let keys = JwtSigningKeys::load_or_create();
let (storage, factory) = SessionMiddlewareFactory::<AppClaims>::build(
Arc::new(keys.encoding_key),
Arc::new(keys.decoding_key),
Algorithm::EdDSA
)
.with_redis_pool(redis.clone())
// Check if header "Authorization" exists and contains Bearer with encoded JWT
.with_jwt_header(JWT_HEADER_NAME)
// Check if cookie JWT exists and contains encoded JWT
.with_jwt_cookie(JWT_COOKIE_NAME)
.with_refresh_header(REFRESH_HEADER_NAME)
// Check if cookie JWT exists and contains encoded JWT
.with_refresh_cookie(REFRESH_COOKIE_NAME)
.finish();
let jwt_ttl = JwtTtl(Duration::days(14));
let refresh_ttl = RefreshTtl(Duration::days(3 * 31));
HttpServer::new(move || {
App::new()
.app_data(Data::new(storage.clone()))
.app_data(Data::new( jwt_ttl ))
.app_data(Data::new( refresh_ttl ))
.wrap(factory.clone())
.app_data(Data::new(redis.clone()))
.service(must_be_signed_in)
.service(may_be_signed_in)
.service(register)
.service(sign_in)
.service(sign_out)
.service(refresh_session)
.service(session_info)
.service(root)
})
.bind(("0.0.0.0", 8080)).unwrap()
.run()
.await.unwrap();
}
#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionData {
id: uuid::Uuid,
subject: String,
}
#[get("/authorized")]
async fn must_be_signed_in(session: Authenticated<AppClaims>) -> HttpResponse {
use crate::actix_jwt_session::Claims;
let jit = session.jti();
HttpResponse::Ok().finish()
}
#[get("/maybe-authorized")]
async fn may_be_signed_in(session: MaybeAuthenticated<AppClaims>) -> HttpResponse {
if let Some(session) = session.into_option() {
}
HttpResponse::Ok().finish()
}
#[derive(Deserialize)]
struct SignUpPayload {
login: String,
password: String,
password_confirmation: String,
}
#[post("/session/sign-up")]
async fn register(payload: Json<SignUpPayload>) -> Result<HttpResponse, actix_web::Error> {
let payload = payload.into_inner();
// Validate payload
// Save model and return HttpResponse
let model = AccountModel {
id: -1,
login: payload.login,
// Encrypt password before saving to database
pass_hash: Hashing::encrypt(&payload.password).unwrap(),
};
// Save model
todo!()
}
#[derive(Deserialize)]
struct SignInPayload {
login: String,
password: String,
}
#[post("/session/sign-in")]
async fn sign_in(
store: Data<SessionStorage>,
payload: Json<SignInPayload>,
jwt_ttl: Data<JwtTtl>,
refresh_ttl: Data<RefreshTtl>,
) -> Result<HttpResponse, actix_web::Error> {
let payload = payload.into_inner();
let store = store.into_inner();
let account: AccountModel = {
/* load account using login */
todo!()
};
if let Err(e) = Hashing::verify(account.pass_hash.as_str(), payload.password.as_str()) {
return Ok(HttpResponse::Unauthorized().finish());
}
let claims = AppClaims {
issues_at: OffsetDateTime::now_utc().unix_timestamp() as usize,
subject: account.login.clone(),
expiration_time: jwt_ttl.0.as_seconds_f64() as u64,
audience: Audience::Web,
jwt_id: uuid::Uuid::new_v4(),
account_id: account.id,
not_before: 0,
};
let pair = store
.clone()
.store(claims, *jwt_ttl.into_inner(), *refresh_ttl.into_inner())
.await
.unwrap();
Ok(HttpResponse::Ok()
.append_header((JWT_HEADER_NAME, pair.jwt.encode().unwrap()))
.append_header((REFRESH_HEADER_NAME, pair.refresh.encode().unwrap()))
.finish())
}
#[post("/session/sign-out")]
async fn sign_out(store: Data<SessionStorage>, auth: Authenticated<AppClaims>) -> HttpResponse {
let store = store.into_inner();
store.erase::<AppClaims>(auth.jwt_id).await.unwrap();
HttpResponse::Ok()
.append_header((JWT_HEADER_NAME, ""))
.append_header((REFRESH_HEADER_NAME, ""))
.cookie(
actix_web::cookie::Cookie::build(JWT_COOKIE_NAME, "")
.expires(OffsetDateTime::now_utc())
.finish(),
)
.cookie(
actix_web::cookie::Cookie::build(REFRESH_COOKIE_NAME, "")
.expires(OffsetDateTime::now_utc())
.finish(),
)
.finish()
}
#[get("/session/info")]
async fn session_info(auth: Authenticated<AppClaims>) -> HttpResponse {
HttpResponse::Ok().json(&*auth)
}
#[get("/session/refresh")]
async fn refresh_session(
auth: Authenticated<RefreshToken>,
storage: Data<SessionStorage>,
) -> HttpResponse {
let storage = storage.into_inner();
storage.refresh(auth.refresh_jti).await.unwrap();
HttpResponse::Ok().json(&*auth)
}
#[get("/")]
async fn root() -> HttpResponse {
HttpResponse::Ok().finish()
}
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum Audience {
Web,
}
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)]
#[serde(rename_all = "snake_case")]
pub struct AppClaims {
#[serde(rename = "exp")]
pub expiration_time: u64,
#[serde(rename = "iat")]
pub issues_at: usize,
/// Account login
#[serde(rename = "sub")]
pub subject: String,
#[serde(rename = "aud")]
pub audience: Audience,
#[serde(rename = "jti")]
pub jwt_id: uuid::Uuid,
#[serde(rename = "aci")]
pub account_id: i32,
#[serde(rename = "nbf")]
pub not_before: u64,
}
impl actix_jwt_session::Claims for AppClaims {
fn jti(&self) -> uuid::Uuid {
self.jwt_id
}
fn subject(&self) -> &str {
&self.subject
}
}
impl AppClaims {
pub fn account_id(&self) -> i32 {
self.account_id
}
}
struct AccountModel {
id: i32,
login: String,
pass_hash: String,
}
```
# Changelog:
1.0.0
* Factory is created using builder pattern
* JSON Web Token has automatically created Refresh Token
* Higher abstraction layers for Middleware, MiddlewareFactory and SessionStorage
* Build-in hashing functions
* Build-in TTL structures
* Documentation
1.0.1
* Returns new pair after refresh lifetime

View File

@ -1,208 +0,0 @@
//! Allow to create own session extractor and extract from cookie or header.
use crate::*;
/// 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 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::*;
/// use std::sync::Arc;
/// use actix_web::HttpMessage;
/// use std::borrow::Cow;
///
/// # #[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 SessionExtractor<Claims> for ExampleExtractor {
/// async fn extract_claims(
/// &self,
/// req: &mut ServiceRequest,
/// jwt_encoding_key: Arc<EncodingKey>,
/// jwt_decoding_key: Arc<DecodingKey>,
/// algorithm: Algorithm,
/// storage: SessionStorage,
/// ) -> 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_token_text<'req>(&self, req: &'req mut ServiceRequest) -> Option<Cow<'req, str>> { None }
/// }
/// ```
async fn extract_claims(
&self,
req: &mut ServiceRequest,
jwt_encoding_key: Arc<EncodingKey>,
jwt_decoding_key: Arc<DecodingKey>,
algorithm: Algorithm,
storage: SessionStorage,
) -> Result<(), Error> {
let Some(as_str) = self.extract_token_text(req).await else {
return Ok(());
};
let decoded_claims = self.decode(&as_str, jwt_decoding_key, algorithm)?;
self.validate(&decoded_claims, storage).await?;
req.extensions_mut().insert(Authenticated {
claims: Arc::new(decoded_claims),
jwt_encoding_key,
algorithm,
});
Ok(())
}
/// Decode encrypted JWT to structure
fn decode(
&self,
value: &str,
jwt_decoding_key: Arc<DecodingKey>,
algorithm: Algorithm,
) -> Result<ClaimsType, Error> {
let mut validation = Validation::new(algorithm);
validation.validate_exp = false;
validation.validate_nbf = false;
validation.leeway = 0;
validation.required_spec_claims.clear();
decode::<ClaimsType>(value, &jwt_decoding_key, &validation)
.map_err(|e| {
#[cfg(feature = "use-tracing")]
tracing::debug!("Failed to decode claims: {e:?}. {e}");
Error::CantDecode
})
.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) -> Result<(), Error> {
let stored = storage
.clone()
.find_jwt::<ClaimsType>(claims.jti())
.await
.map_err(|e| {
#[cfg(feature = "use-tracing")]
tracing::debug!(
"Failed to load {} from storage: {e:?}",
std::any::type_name::<ClaimsType>()
);
Error::LoadError
})?;
if &stored != claims {
#[cfg(feature = "use-tracing")]
tracing::debug!("{claims:?} != {stored:?}");
Err(Error::DontMatch)
} else {
Ok(())
}
}
/// Lookup for session data as a string in [actix_web::dev::ServiceRequest]
///
/// If there's no token data in request you should returns `None`. This is not considered as an
/// error and until endpoint requires `Authenticated` this will not results in `401`.
async fn extract_token_text<'req>(
&self,
req: &'req mut ServiceRequest,
) -> Option<Cow<'req, str>>;
}
/// 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> {
/// Creates new cookie extractor.
/// It will extract token data from cookie with given name
pub fn new(cookie_name: &'static str) -> Self {
Self {
__ty: Default::default(),
cookie_name,
}
}
}
#[async_trait(?Send)]
impl<ClaimsType: Claims> SessionExtractor<ClaimsType> for CookieExtractor<ClaimsType> {
async fn extract_token_text<'req>(
&self,
req: &'req mut ServiceRequest,
) -> Option<Cow<'req, str>> {
req.cookie(self.cookie_name)
.map(|c| c.value().to_string().into())
}
}
/// 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 anchor (tag A) you should use [CookieExtractor]
pub struct HeaderExtractor<ClaimsType> {
__ty: PhantomData<ClaimsType>,
header_name: &'static str,
}
impl<ClaimsType: Claims> HeaderExtractor<ClaimsType> {
/// Creates new header extractor.
/// It will extract token data from header with given name
pub fn new(header_name: &'static str) -> Self {
Self {
__ty: Default::default(),
header_name,
}
}
}
#[async_trait(?Send)]
impl<ClaimsType: Claims> SessionExtractor<ClaimsType> for HeaderExtractor<ClaimsType> {
async fn extract_token_text<'req>(
&self,
req: &'req mut ServiceRequest,
) -> Option<Cow<'req, str>> {
req.headers()
.get(self.header_name)
.and_then(|h| h.to_str().ok())
.map(|h| h.to_owned().into())
}
}

View File

@ -1,41 +0,0 @@
//! Encrypting and decrypting password
//!
//! This module is available by default or by enabling `hashing` feature.
//! Library docs covers using it in context of `register` and `sign in`.
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
/// Encrypting and decrypting password
pub struct Hashing;
impl Hashing {
/// Takes password and returns encrypted hash with random salt
pub fn encrypt(password: &str) -> argon2::password_hash::Result<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
}
/// Takes password hash and password and validates it.
pub fn verify(password_hash: &str, password: &str) -> argon2::password_hash::Result<()> {
let parsed_hash = PasswordHash::new(password_hash)?;
Argon2::default().verify_password(password.as_bytes(), &parsed_hash)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_always_random_salt() {
let pass = "ahs9dya8tsd7fa8tsa86tT&^R%^DS^%ARS&A";
let hash = Hashing::encrypt(pass).unwrap();
assert!(Hashing::verify(hash.as_str(), pass).is_ok());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,295 +0,0 @@
//! Create session storage and build middleware factory
use crate::*;
pub use actix_web::cookie::time::{Duration, OffsetDateTime};
use actix_web::dev::Transform;
use actix_web::dev::{forward_ready, Service, ServiceRequest, ServiceResponse};
use futures_util::future::LocalBoxFuture;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey};
use std::future::{ready, Ready};
use std::rc::Rc;
use std::sync::Arc;
/// Session middleware factory builder
///
/// It should be constructed with [SessionMiddlewareFactory::build].
pub struct SessionMiddlewareBuilder<ClaimsType: Claims> {
pub(crate) jwt_encoding_key: Arc<EncodingKey>,
pub(crate) jwt_decoding_key: Arc<DecodingKey>,
pub(crate) algorithm: Algorithm,
pub(crate) storage: Option<SessionStorage>,
pub(crate) jwt_extractors: Vec<Box<dyn SessionExtractor<ClaimsType>>>,
pub(crate) refresh_extractors: Vec<Box<dyn SessionExtractor<RefreshToken>>>,
}
impl<ClaimsType: Claims> SessionMiddlewareBuilder<ClaimsType> {
#[doc(hidden)]
pub(crate) fn new(
jwt_encoding_key: Arc<EncodingKey>,
jwt_decoding_key: Arc<DecodingKey>,
algorithm: Algorithm,
) -> Self {
Self {
jwt_encoding_key: jwt_encoding_key.clone(),
jwt_decoding_key,
algorithm,
storage: None,
jwt_extractors: vec![],
refresh_extractors: vec![],
}
}
/// Set session storage to given instance. Good if for some reason you need to share 1 storage
/// with multiple instances of session middleware
#[must_use]
pub fn with_storage(mut self, storage: SessionStorage) -> Self {
self.storage = Some(storage);
self
}
/// Add cookie extractor for refresh token.
#[must_use]
pub fn with_refresh_cookie(mut self, name: &'static str) -> Self {
self.refresh_extractors
.push(Box::new(CookieExtractor::<RefreshToken>::new(name)));
self
}
/// Add header extractor for refresh token.
#[must_use]
pub fn with_refresh_header(mut self, name: &'static str) -> Self {
self.refresh_extractors
.push(Box::new(HeaderExtractor::<RefreshToken>::new(name)));
self
}
/// Add cookie extractor for json web token.
#[must_use]
pub fn with_jwt_cookie(mut self, name: &'static str) -> Self {
self.jwt_extractors
.push(Box::new(CookieExtractor::<ClaimsType>::new(name)));
self
}
/// Add header extractor for json web token.
#[must_use]
pub fn with_jwt_header(mut self, name: &'static str) -> Self {
self.jwt_extractors
.push(Box::new(HeaderExtractor::<ClaimsType>::new(name)));
self
}
/// Builds middleware factory and returns session storage with factory
pub fn finish(self) -> (SessionStorage, SessionMiddlewareFactory<ClaimsType>) {
let Self {
storage,
jwt_encoding_key,
jwt_decoding_key,
algorithm,
jwt_extractors,
refresh_extractors,
..
} = self;
let storage = storage
.expect("Session storage must be constracted from pool or set from existing storage");
(
storage.clone(),
SessionMiddlewareFactory {
jwt_encoding_key,
jwt_decoding_key,
algorithm,
storage,
jwt_extractors: Arc::new(jwt_extractors),
refresh_extractors: Arc::new(refresh_extractors),
},
)
}
}
/// Factory creates middlware for every single request.
///
/// All fields here are immutable and have atomic access and only pointer is copied so are very cheap
///
/// Example:
///
/// ```
/// use std::sync::Arc;
/// use actix_jwt_session::*;
///
/// # async fn create<AppClaims: actix_jwt_session::Claims>() {
/// // create redis connection
/// let redis = {
/// use redis_async_pool::{RedisConnectionManager, RedisPool};
/// RedisPool::new(
/// RedisConnectionManager::new(
/// redis::Client::open("redis://localhost:6379").expect("Fail to connect to redis"),
/// true,
/// None,
/// ),
/// 5,
/// )
/// };
///
/// // load or create new keys in `./config`
/// let keys = JwtSigningKeys::load_or_create();
///
/// // create new [SessionStorage] and [SessionMiddlewareFactory]
/// let (storage, factory) = SessionMiddlewareFactory::<AppClaims>::build(
/// Arc::new(keys.encoding_key),
/// Arc::new(keys.decoding_key),
/// Algorithm::EdDSA
/// )
/// // pass redis connection
/// .with_redis_pool(redis.clone())
/// // Check if header "Authorization" exists and contains Bearer with encoded JWT
/// .with_jwt_header("Authorization")
/// // Check if cookie "jwt" exists and contains encoded JWT
/// .with_jwt_cookie("acx-a")
/// .with_refresh_header("ACX-Refresh")
/// // Check if cookie "jwt" exists and contains encoded JWT
/// .with_refresh_cookie("acx-r")
/// .finish();
/// # }
/// ```
#[derive(Clone)]
pub struct SessionMiddlewareFactory<ClaimsType: Claims> {
pub(crate) jwt_encoding_key: Arc<EncodingKey>,
pub(crate) jwt_decoding_key: Arc<DecodingKey>,
pub(crate) algorithm: Algorithm,
pub(crate) storage: SessionStorage,
pub(crate) jwt_extractors: Arc<Vec<Box<dyn SessionExtractor<ClaimsType>>>>,
pub(crate) refresh_extractors: Arc<Vec<Box<dyn SessionExtractor<RefreshToken>>>>,
}
impl<ClaimsType: Claims> SessionMiddlewareFactory<ClaimsType> {
pub fn build(
jwt_encoding_key: Arc<EncodingKey>,
jwt_decoding_key: Arc<DecodingKey>,
algorithm: Algorithm,
) -> SessionMiddlewareBuilder<ClaimsType> {
SessionMiddlewareBuilder::new(jwt_encoding_key, jwt_decoding_key, algorithm)
}
}
impl<S, B, ClaimsType> Transform<S, ServiceRequest> for SessionMiddlewareFactory<ClaimsType>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static,
ClaimsType: Claims,
{
type Response = ServiceResponse<B>;
type Error = actix_web::Error;
type Transform = SessionMiddleware<S, ClaimsType>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(SessionMiddleware {
service: Rc::new(service),
storage: self.storage.clone(),
jwt_encoding_key: self.jwt_encoding_key.clone(),
jwt_decoding_key: self.jwt_decoding_key.clone(),
algorithm: self.algorithm,
jwt_extractors: self.jwt_extractors.clone(),
refresh_extractors: self.refresh_extractors.clone(),
}))
}
}
#[doc(hidden)]
pub struct SessionMiddleware<S, ClaimsType>
where
ClaimsType: Claims,
{
pub(crate) service: Rc<S>,
pub(crate) jwt_encoding_key: Arc<EncodingKey>,
pub(crate) jwt_decoding_key: Arc<DecodingKey>,
pub(crate) algorithm: Algorithm,
pub(crate) storage: SessionStorage,
pub(crate) jwt_extractors: Arc<Vec<Box<dyn SessionExtractor<ClaimsType>>>>,
pub(crate) refresh_extractors: Arc<Vec<Box<dyn SessionExtractor<RefreshToken>>>>,
}
impl<S, ClaimsType: Claims> SessionMiddleware<S, ClaimsType> {
async fn extract_token<C: Claims>(
req: &mut ServiceRequest,
jwt_encoding_key: Arc<EncodingKey>,
jwt_decoding_key: Arc<DecodingKey>,
algorithm: Algorithm,
storage: SessionStorage,
extractors: &[Box<dyn SessionExtractor<C>>],
) -> Result<(), Error> {
let mut last_error = None;
for extractor in extractors.iter() {
match extractor
.extract_claims(
req,
jwt_encoding_key.clone(),
jwt_decoding_key.clone(),
algorithm,
storage.clone(),
)
.await
{
Ok(_) => break,
Err(e) => {
last_error = Some(e);
}
};
}
if let Some(e) = last_error {
return Err(e)?;
}
Ok(())
}
}
impl<S, B, ClaimsType> Service<ServiceRequest> for SessionMiddleware<S, ClaimsType>
where
ClaimsType: Claims,
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static,
{
type Response = ServiceResponse<B>;
type Error = actix_web::Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, mut req: ServiceRequest) -> Self::Future {
use futures_lite::FutureExt;
let svc = self.service.clone();
let jwt_decoding_key = self.jwt_decoding_key.clone();
let jwt_encoding_key = self.jwt_encoding_key.clone();
let algorithm = self.algorithm;
let storage = self.storage.clone();
let jwt_extractors = self.jwt_extractors.clone();
let refresh_extractors = self.refresh_extractors.clone();
async move {
if !jwt_extractors.is_empty() {
Self::extract_token(
&mut req,
jwt_encoding_key.clone(),
jwt_decoding_key.clone(),
algorithm,
storage.clone(),
&jwt_extractors,
)
.await?;
}
if !refresh_extractors.is_empty() {
Self::extract_token(
&mut req,
jwt_encoding_key,
jwt_decoding_key,
algorithm,
storage,
&refresh_extractors,
)
.await?;
}
let res = svc.call(req).await?;
Ok(res)
}
.boxed_local()
}
}

View File

@ -1,195 +0,0 @@
//! 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 crate::*;
use redis::aio::ConnectionLike;
use redis::AsyncCommands;
use std::marker::PhantomData;
use std::sync::Arc;
/// Redis implementation for [TokenStorage]
#[derive(Clone)]
struct RedisStorage<ClaimsType: Claims> {
pool: redis_async_pool::RedisPool,
_claims_type_marker: PhantomData<ClaimsType>,
}
impl<ClaimsType: Claims> RedisStorage<ClaimsType> {
pub fn new(pool: redis_async_pool::RedisPool) -> Self {
Self {
pool,
_claims_type_marker: Default::default(),
}
}
}
#[async_trait::async_trait(?Send)]
impl<ClaimsType> TokenStorage for RedisStorage<ClaimsType>
where
ClaimsType: Claims,
{
async fn get_by_jti(self: Arc<Self>, jti: &[u8]) -> Result<Vec<u8>, Error> {
let pool = self.pool.clone();
let mut conn = pool.get().await.map_err(|e| {
#[cfg(feature = "use-tracing")]
tracing::error!("Unable to obtain redis connection: {e}");
Error::RedisConn
})?;
conn.get::<_, Vec<u8>>(jti).await.map_err(|e| {
#[cfg(feature = "use-tracing")]
tracing::error!("Session record not found in redis: {e}");
Error::NotFound
})
}
async fn set_by_jti(
self: Arc<Self>,
jwt_jti: &[u8],
refresh_jti: &[u8],
bytes: &[u8],
mut exp: Duration,
) -> Result<(), Error> {
bad_ttl!(
exp,
Duration::seconds(1),
"Expiration time is bellow 1s. This is not allowed for redis server."
);
let pool = self.pool.clone();
let mut conn = pool.get().await.map_err(|e| {
#[cfg(feature = "use-tracing")]
tracing::error!("Unable to obtain redis connection: {e}");
Error::RedisConn
})?;
let mut pipeline = redis::Pipeline::new();
pipeline
.set_ex(jwt_jti, bytes, exp.as_seconds_f32() as usize)
.set_ex(refresh_jti, bytes, exp.as_seconds_f32() as usize);
conn.req_packed_commands(&pipeline, 0, 2)
.await
.map_err(|e| {
#[cfg(feature = "use-tracing")]
tracing::error!("Failed to save session in redis: {e}");
Error::WriteFailed
})?;
Ok(())
}
async fn remove_by_jti(self: Arc<Self>, jti: &[u8]) -> Result<(), Error> {
let pool = self.pool.clone();
let mut conn = pool.get().await.map_err(|e| {
#[cfg(feature = "use-tracing")]
tracing::error!("Unable to obtain redis connection: {e}");
Error::RedisConn
})?;
conn.del(jti).await.map_err(|e| {
#[cfg(feature = "use-tracing")]
tracing::error!("Session record can't be removed from redis: {e}");
Error::NotFound
})?;
Ok(())
}
}
impl<ClaimsType: Claims> SessionMiddlewareBuilder<ClaimsType> {
#[must_use]
pub fn with_redis_pool(mut self, pool: redis_async_pool::RedisPool) -> Self {
let storage = Arc::new(RedisStorage::<ClaimsType>::new(pool));
let storage = SessionStorage::new(storage, self.jwt_encoding_key.clone(), self.algorithm);
self.storage = Some(storage);
self
}
}
#[cfg(test)]
mod tests {
use actix_web::cookie::time::*;
use super::*;
use std::ops::Add;
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)]
#[serde(rename_all = "snake_case")]
pub struct Claims {
#[serde(rename = "exp")]
pub expires_at: usize,
#[serde(rename = "iat")]
pub issues_at: usize,
/// Account login
#[serde(rename = "sub")]
pub subject: String,
#[serde(rename = "aud")]
pub audience: String,
#[serde(rename = "jti")]
pub jwt_id: uuid::Uuid,
#[serde(rename = "aci")]
pub account_id: i32,
}
impl crate::Claims for Claims {
fn jti(&self) -> uuid::Uuid {
self.jwt_id
}
fn subject(&self) -> &str {
&self.subject
}
}
async fn create_storage() -> (SessionStorage, SessionMiddlewareFactory<Claims>) {
let redis = {
use redis_async_pool::{RedisConnectionManager, RedisPool};
RedisPool::new(
RedisConnectionManager::new(
redis::Client::open("redis://localhost:6379")
.expect("Fail to connect to redis"),
true,
None,
),
5,
)
};
let jwt_signing_keys = JwtSigningKeys::generate(false).unwrap();
SessionMiddlewareFactory::<Claims>::build(
Arc::new(jwt_signing_keys.encoding_key),
Arc::new(jwt_signing_keys.decoding_key),
Algorithm::EdDSA,
)
.with_redis_pool(redis)
.with_refresh_cookie(REFRESH_COOKIE_NAME)
.with_refresh_header(REFRESH_HEADER_NAME)
.with_jwt_cookie(JWT_COOKIE_NAME)
.with_jwt_header(JWT_HEADER_NAME)
.finish()
}
#[tokio::test]
async fn check_encode() {
let (store, _) = create_storage().await;
let jwt_exp = JwtTtl(Duration::days(31));
let refresh_exp = RefreshTtl(Duration::days(31));
let original = Claims {
subject: "me".into(),
expires_at: OffsetDateTime::now_utc()
.add(Duration::days(31))
.unix_timestamp() as usize,
issues_at: OffsetDateTime::now_utc().unix_timestamp() as usize,
audience: "web".into(),
jwt_id: Uuid::new_v4(),
account_id: 24234,
};
store
.store(original.clone(), jwt_exp, refresh_exp)
.await
.unwrap();
let loaded = store.find_jwt(original.jwt_id).await.unwrap();
assert_eq!(original, loaded);
}
}

View File

@ -1,260 +0,0 @@
use std::sync::Arc;
use actix_jwt_session::*;
use actix_web::dev::ServiceResponse;
use actix_web::http::{Method, StatusCode};
use actix_web::web::{Data, Json};
use actix_web::HttpResponse;
use actix_web::{get, post};
use actix_web::{http::header::ContentType, test, App};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct Claims {
id: Uuid,
subject: String,
}
impl actix_jwt_session::Claims for Claims {
fn jti(&self) -> Uuid {
self.id
}
fn subject(&self) -> &str {
&self.subject
}
}
#[tokio::test(flavor = "multi_thread")]
async fn full_flow() {
let redis = {
use redis_async_pool::{RedisConnectionManager, RedisPool};
RedisPool::new(
RedisConnectionManager::new(
redis::Client::open("redis://localhost:6379").expect("Fail to connect to redis"),
true,
None,
),
5,
)
};
let keys = JwtSigningKeys::generate(false).unwrap();
let (storage, factory) = SessionMiddlewareFactory::<Claims>::build(
Arc::new(keys.encoding_key),
Arc::new(keys.decoding_key),
Algorithm::EdDSA,
)
.with_redis_pool(redis.clone())
.with_jwt_header(JWT_HEADER_NAME)
.with_refresh_header(REFRESH_HEADER_NAME)
.with_jwt_cookie(JWT_COOKIE_NAME)
.with_refresh_cookie(REFRESH_COOKIE_NAME)
.finish();
let app = App::new()
.app_data(Data::new(storage.clone()))
.wrap(factory.clone())
.app_data(Data::new(redis.clone()))
.app_data(Data::new(JwtTtl(Duration::seconds(1))))
.app_data(Data::new(RefreshTtl(Duration::seconds(30))))
.service(sign_in)
.service(sign_out)
.service(session)
.service(refresh_session)
.service(root);
let app = actix_web::test::init_service(app).await;
// -----------------------------------------------------------------------------
// Assert authorization is ignored when token is not needed
// -----------------------------------------------------------------------------
let res = test::call_service(
&app,
test::TestRequest::default()
.insert_header(ContentType::plaintext())
.to_request(),
)
.await;
assert!(res.status().is_success());
// -----------------------------------------------------------------
// Assert signed out when active session
// -----------------------------------------------------------------
let res = test::call_service(&app, session_request("", "").to_request()).await;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
let origina_claims = Claims {
id: Uuid::new_v4(),
subject: "foo".to_string(),
};
// ----------------------------------------------
// Create session
// ----------------------------------------------
println!("-> Creating session");
let res = test::call_service(
&app,
test::TestRequest::default()
.uri("/session/sign-in")
.method(actix_web::http::Method::POST)
.insert_header(ContentType::json())
.set_json(&origina_claims)
.to_request(),
)
.await;
assert_eq!(res.status(), StatusCode::OK);
println!(" <- OK");
let auth_bearer = res
.headers()
.get(JWT_HEADER_NAME)
.unwrap()
.to_str()
.unwrap();
let refresh_bearer = res
.headers()
.get(REFRESH_HEADER_NAME)
.unwrap()
.to_str()
.unwrap();
// ----------------------------------------------
// Assert signed in
// ----------------------------------------------
println!("-> Assert signed in");
let res = test::call_service(
&app,
session_request(&auth_bearer, &refresh_bearer).to_request(),
)
.await;
assert_eq!(res.status(), StatusCode::OK);
println!(" <- OK");
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
// ----------------------------------------------
// Access Token TTL expires
// ----------------------------------------------
println!("-> Access Token TTL expires");
let res = test::try_call_service(
&app,
session_request(&auth_bearer, &refresh_bearer).to_request(),
)
.await;
expect_invalid_session(res);
println!(" <- OK");
// ----------------------------------------------
// Refresh token
// ----------------------------------------------
println!("-> Refresh token");
let res = test::call_service(
&app,
test::TestRequest::default()
.uri("/session/refresh")
.method(Method::GET)
.insert_header((REFRESH_HEADER_NAME, refresh_bearer))
.to_request(),
)
.await;
assert_eq!(res.status(), StatusCode::OK);
println!(" <- OK");
// ----------------------------------------------
// Logout
// ----------------------------------------------
println!("-> Logout");
let res = test::call_service(
&app,
test::TestRequest::default()
.uri("/session/sign-out")
.method(Method::POST)
.insert_header((JWT_HEADER_NAME, auth_bearer))
.insert_header((REFRESH_HEADER_NAME, refresh_bearer))
.to_request(),
)
.await;
assert_eq!(res.status(), StatusCode::OK);
println!(" <- OK");
// --------------------------------------------------------------
// Assert signed out - session destroyed
// --------------------------------------------------------------
println!("-> Assert signed out - session destroyed");
let res = test::try_call_service(
&app,
session_request(&auth_bearer, &refresh_bearer).to_request(),
)
.await;
expect_invalid_session(res);
println!(" <- OK");
}
#[post("/session/sign-in")]
async fn sign_in(
store: Data<SessionStorage>,
claims: Json<Claims>,
jwt_ttl: Data<JwtTtl>,
refresh_ttl: Data<RefreshTtl>,
) -> Result<HttpResponse, actix_web::Error> {
let claims = claims.into_inner();
let store = store.into_inner();
let pair = store
.clone()
.store(claims, *jwt_ttl.into_inner(), *refresh_ttl.into_inner())
.await
.unwrap();
Ok(HttpResponse::Ok()
.append_header((JWT_HEADER_NAME, pair.jwt.encode().unwrap()))
.append_header((REFRESH_HEADER_NAME, pair.refresh.encode().unwrap()))
.finish())
}
#[post("/session/sign-out")]
async fn sign_out(store: Data<SessionStorage>, auth: Authenticated<Claims>) -> HttpResponse {
let store = store.into_inner();
store.erase::<Claims>(auth.id).await.unwrap();
HttpResponse::Ok().finish()
}
#[get("/session/info")]
async fn session(auth: Authenticated<Claims>) -> HttpResponse {
HttpResponse::Ok().json(&*auth)
}
#[get("/session/refresh")]
async fn refresh_session(
auth: Authenticated<RefreshToken>,
storage: Data<SessionStorage>,
) -> HttpResponse {
let storage = storage.into_inner();
storage.refresh::<Claims>(auth.refresh_jti).await.unwrap();
HttpResponse::Ok().json(&*auth)
}
#[get("/")]
async fn root() -> HttpResponse {
HttpResponse::Ok().finish()
}
fn session_request(auth_bearer: &str, refresh_bearer: &str) -> actix_web::test::TestRequest {
let req = test::TestRequest::default()
.uri("/session/info")
.method(Method::GET);
if !auth_bearer.is_empty() {
req.insert_header((JWT_HEADER_NAME, auth_bearer))
.insert_header((REFRESH_HEADER_NAME, refresh_bearer))
} else {
req
}
}
fn expect_invalid_session(res: Result<ServiceResponse, actix_web::Error>) {
let err = res
.expect_err("Must be unauthorized")
.as_error::<actix_jwt_session::Error>()
.expect("Must be authorization error")
.clone();
assert_eq!(err, actix_jwt_session::Error::LoadError);
}

View File

@ -5,7 +5,6 @@ edition = "2021"
[dependencies]
actix-http = "3.3.1"
actix-jwt-authc = { version = "0.2.0", features = ["tracing", "session"] }
actix-web = "4.3.1"
argon2 = "0.5.1"
askama = { version = "0.12.0", features = ["serde", "with-actix-web", "comrak", "mime"] }
@ -17,7 +16,7 @@ garde = { version = "0.14.0", features = ["derive"] }
jsonwebtoken = "8.3.0"
oswilno-contract = { path = "../oswilno-contract" }
oswilno-view = { path = "../oswilno-view" }
actix-jwt-session = { path = "../actix-jwt-session", features = ["use-redis"] }
actix-jwt-session = { version = "*", features = ["use-redis"] }
rand = "0.8.5"
redis = { version = "0.17" }
redis-async-pool = "0.2.4"

View File

@ -10,4 +10,4 @@ askama_actix = { version = "0.14.0" }
futures-core = "0.3.28"
garde = { version = "0.14.0", features = ["derive"] }
tracing = "0.1.37"
actix-jwt-session = { path = "../actix-jwt-session" }
actix-jwt-session = { version = "*" }