From 0b145bec5dbf80d919ee9a93913276d6483ddf4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Wo=C5=BAniak?= Date: Mon, 4 Sep 2023 12:59:23 +0200 Subject: [PATCH] Clear workspace --- Cargo.lock | 43 +- Cargo.toml | 1 - crates/actix-jwt-session/Cargo.toml | 44 - crates/actix-jwt-session/README.md | 394 ----- crates/actix-jwt-session/src/extractors.rs | 208 --- crates/actix-jwt-session/src/hashing.rs | 41 - crates/actix-jwt-session/src/lib.rs | 1271 ----------------- crates/actix-jwt-session/src/middleware.rs | 295 ---- crates/actix-jwt-session/src/redis_adapter.rs | 195 --- .../tests/ensure_redis_flow.rs | 260 ---- crates/oswilno-session/Cargo.toml | 3 +- crates/oswilno-view/Cargo.toml | 2 +- 12 files changed, 6 insertions(+), 2751 deletions(-) delete mode 100644 crates/actix-jwt-session/Cargo.toml delete mode 100644 crates/actix-jwt-session/README.md delete mode 100644 crates/actix-jwt-session/src/extractors.rs delete mode 100644 crates/actix-jwt-session/src/hashing.rs delete mode 100644 crates/actix-jwt-session/src/lib.rs delete mode 100644 crates/actix-jwt-session/src/middleware.rs delete mode 100644 crates/actix-jwt-session/src/redis_adapter.rs delete mode 100644 crates/actix-jwt-session/tests/ensure_redis_flow.rs diff --git a/Cargo.lock b/Cargo.lock index 52039b8..8892365 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 13e994b..ea7e3de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,5 @@ members = [ './crates/oswilno-parking-space', './crates/migration', './crates/oswilno-actix-admin', - './crates/actix-jwt-session', ] resolver = "2" diff --git a/crates/actix-jwt-session/Cargo.toml b/crates/actix-jwt-session/Cargo.toml deleted file mode 100644 index 58ea327..0000000 --- a/crates/actix-jwt-session/Cargo.toml +++ /dev/null @@ -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" diff --git a/crates/actix-jwt-session/README.md b/crates/actix-jwt-session/README.md deleted file mode 100644 index 6d4f6a9..0000000 --- a/crates/actix-jwt-session/README.md +++ /dev/null @@ -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, - refresh: Option, -} -``` - -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() { - // 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::::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::::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) -> 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) -> 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) -> Result { - 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, - payload: Json, - jwt_ttl: Data, - refresh_ttl: Data, -) -> Result { - 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, auth: Authenticated) -> HttpResponse { - let store = store.into_inner(); - store.erase::(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) -> HttpResponse { - HttpResponse::Ok().json(&*auth) -} - -#[get("/session/refresh")] -async fn refresh_session( - auth: Authenticated, - storage: Data, -) -> 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 diff --git a/crates/actix-jwt-session/src/extractors.rs b/crates/actix-jwt-session/src/extractors.rs deleted file mode 100644 index 490ad8e..0000000 --- a/crates/actix-jwt-session/src/extractors.rs +++ /dev/null @@ -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::>()` 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: 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 for ExampleExtractor { - /// async fn extract_claims( - /// &self, - /// req: &mut ServiceRequest, - /// jwt_encoding_key: Arc, - /// jwt_decoding_key: Arc, - /// 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> { None } - /// } - /// ``` - async fn extract_claims( - &self, - req: &mut ServiceRequest, - jwt_encoding_key: Arc, - jwt_decoding_key: Arc, - 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, - algorithm: Algorithm, - ) -> Result { - let mut validation = Validation::new(algorithm); - validation.validate_exp = false; - validation.validate_nbf = false; - validation.leeway = 0; - validation.required_spec_claims.clear(); - - decode::(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::(claims.jti()) - .await - .map_err(|e| { - #[cfg(feature = "use-tracing")] - tracing::debug!( - "Failed to load {} from storage: {e:?}", - std::any::type_name::() - ); - 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>; -} - -/// 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 { - __ty: PhantomData, - cookie_name: &'static str, -} - -impl CookieExtractor { - /// 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 SessionExtractor for CookieExtractor { - async fn extract_token_text<'req>( - &self, - req: &'req mut ServiceRequest, - ) -> Option> { - 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 { - __ty: PhantomData, - header_name: &'static str, -} - -impl HeaderExtractor { - /// 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 SessionExtractor for HeaderExtractor { - async fn extract_token_text<'req>( - &self, - req: &'req mut ServiceRequest, - ) -> Option> { - req.headers() - .get(self.header_name) - .and_then(|h| h.to_str().ok()) - .map(|h| h.to_owned().into()) - } -} diff --git a/crates/actix-jwt-session/src/hashing.rs b/crates/actix-jwt-session/src/hashing.rs deleted file mode 100644 index 2a3ca00..0000000 --- a/crates/actix-jwt-session/src/hashing.rs +++ /dev/null @@ -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 { - 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()); - } -} diff --git a/crates/actix-jwt-session/src/lib.rs b/crates/actix-jwt-session/src/lib.rs deleted file mode 100644 index f0dd0b5..0000000 --- a/crates/actix-jwt-session/src/lib.rs +++ /dev/null @@ -1,1271 +0,0 @@ -//! 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: -//! -//! ``` -//! use serde::Deserialize; -//! -//! #[derive(Deserialize)] -//! struct MyJsonBody { -//! jwt: Option, -//! refresh: Option, -//! } -//! ``` -//! -//! To start with this library you need to create your own `AppClaims` structure and implement -//! `actix_jwt_session::Claims` trait for it. -//! -//! ``` -//! 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 -//! -//! ``` -//! use std::sync::Arc; -//! use actix_jwt_session::*; -//! -//! # async fn create() { -//! // 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::::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. -//! -//! ``` -//! use actix_jwt_session::{JwtTtl, RefreshTtl, Duration}; -//! -//! 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: -//! -//! ```no_run -//! 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::::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) -> 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) -> 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) -> Result { -//! 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, -//! payload: Json, -//! jwt_ttl: Data, -//! refresh_ttl: Data, -//! ) -> Result { -//! 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, auth: Authenticated) -> HttpResponse { -//! let store = store.into_inner(); -//! store.erase::(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) -> HttpResponse { -//! HttpResponse::Ok().json(&*auth) -//! } -//! -//! #[get("/session/refresh")] -//! async fn refresh_session( -//! refresh_token: Authenticated, -//! storage: Data, -//! ) -> HttpResponse { -//! let s = storage.into_inner(); -//! let pair = match s.refresh::(refresh_token.access_jti()).await { -//! Err(e) => { -//! tracing::warn!("Failed to refresh token: {e}"); -//! return HttpResponse::BadRequest().finish(); -//! } -//! Ok(pair) => pair, -//! }; -//! -//! let encrypted_jwt = match pair.jwt.encode() { -//! Ok(text) => text, -//! Err(e) => { -//! tracing::warn!("Failed to encode claims: {e}"); -//! return HttpResponse::InternalServerError().finish(); -//! } -//! }; -//! let encrypted_refresh = match pair.refresh.encode() { -//! Err(e) => { -//! tracing::warn!("Failed to encode claims: {e}"); -//! return HttpResponse::InternalServerError().finish(); -//! } -//! Ok(text) => text, -//! }; -//! HttpResponse::Ok() -//! .append_header(( -//! actix_jwt_session::JWT_HEADER_NAME, -//! format!("Bearer {encrypted_jwt}").as_str(), -//! )) -//! .append_header(( -//! actix_jwt_session::REFRESH_HEADER_NAME, -//! format!("Bearer {}", encrypted_refresh).as_str(), -//! )) -//! .append_header(( -//! "ACX-JWT-TTL", -//! (pair.refresh.issues_at + pair.refresh.refresh_ttl.0).to_string(), -//! )) -//! .finish() -//! } -//! -//! #[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, -//! } -//! ``` - -pub use actix_web::cookie::time::{Duration, OffsetDateTime}; -use actix_web::dev::ServiceRequest; -use actix_web::HttpResponse; -use actix_web::{FromRequest, HttpMessage}; -use async_trait::async_trait; -pub use jsonwebtoken::Algorithm; -use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Validation}; -use serde::Deserialize; -use serde::{de::DeserializeOwned, Serialize}; -use std::borrow::Cow; -use std::marker::PhantomData; -use std::sync::Arc; -pub use uuid::Uuid; - -/// This is maximum duration of json web token after which token will be invalid and depends on -/// implementation removed. -/// -/// This value should never be lower than 1 second since some implementations don't accept values -/// lower than 1s. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(transparent)] -pub struct JwtTtl(pub Duration); - -/// This is maximum duration of refresh token after which token will be invalid and depends on -/// implementation removed -/// -/// This value should never be lower than 1 second since some implementations don't accept values -/// lower than 1s. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(transparent)] -pub struct RefreshTtl(pub Duration); - -/// Default json web token header name -/// -/// Examples: -/// -/// ``` -/// use actix_web::{get, HttpResponse, cookie::Cookie}; -/// use actix_jwt_session::*; -/// -/// async fn create_response(pair: Pair) -> HttpResponse { -/// let jwt_text = pair.jwt.encode().unwrap(); -/// let refresh_text = pair.refresh.encode().unwrap(); -/// HttpResponse::Ok() -/// .append_header((JWT_HEADER_NAME, jwt_text.as_str())) -/// .append_header((REFRESH_HEADER_NAME, refresh_text.as_str())) -/// .cookie( -/// actix_web::cookie::Cookie::build(JWT_COOKIE_NAME, jwt_text.as_str()) -/// .finish() -/// ) -/// .cookie( -/// actix_web::cookie::Cookie::build(REFRESH_COOKIE_NAME, refresh_text.as_str()) -/// .finish() -/// ) -/// .finish() -/// } -/// ``` -pub static JWT_HEADER_NAME: &str = "Authorization"; - -/// Default refresh token header name -/// -/// Examples: -/// -/// ``` -/// use actix_web::{get, HttpResponse, cookie::Cookie}; -/// use actix_jwt_session::*; -/// -/// async fn create_response(pair: Pair) -> HttpResponse { -/// let jwt_text = pair.jwt.encode().unwrap(); -/// let refresh_text = pair.refresh.encode().unwrap(); -/// HttpResponse::Ok() -/// .append_header((JWT_HEADER_NAME, jwt_text.as_str())) -/// .append_header((REFRESH_HEADER_NAME, refresh_text.as_str())) -/// .cookie( -/// actix_web::cookie::Cookie::build(JWT_COOKIE_NAME, jwt_text.as_str()) -/// .finish() -/// ) -/// .cookie( -/// actix_web::cookie::Cookie::build(REFRESH_COOKIE_NAME, refresh_text.as_str()) -/// .finish() -/// ) -/// .finish() -/// } -/// ``` -pub static REFRESH_HEADER_NAME: &str = "ACX-Refresh"; - -/// Default json web token cookie name -/// -/// Examples: -/// -/// ``` -/// use actix_web::{get, HttpResponse, cookie::Cookie}; -/// use actix_jwt_session::*; -/// -/// async fn create_response(pair: Pair) -> HttpResponse { -/// let jwt_text = pair.jwt.encode().unwrap(); -/// let refresh_text = pair.refresh.encode().unwrap(); -/// HttpResponse::Ok() -/// .append_header((JWT_HEADER_NAME, jwt_text.as_str())) -/// .append_header((REFRESH_HEADER_NAME, refresh_text.as_str())) -/// .cookie( -/// actix_web::cookie::Cookie::build(JWT_COOKIE_NAME, jwt_text.as_str()) -/// .finish() -/// ) -/// .cookie( -/// actix_web::cookie::Cookie::build(REFRESH_COOKIE_NAME, refresh_text.as_str()) -/// .finish() -/// ) -/// .finish() -/// } -/// ``` -pub static JWT_COOKIE_NAME: &str = "ACX-Auth"; - -/// Default refresh token cookie name -/// -/// Examples: -/// -/// ``` -/// use actix_web::{get, HttpResponse, cookie::Cookie}; -/// use actix_jwt_session::*; -/// -/// async fn create_response(pair: Pair) -> HttpResponse { -/// let jwt_text = pair.jwt.encode().unwrap(); -/// let refresh_text = pair.refresh.encode().unwrap(); -/// HttpResponse::Ok() -/// .append_header((JWT_HEADER_NAME, jwt_text.as_str())) -/// .append_header((REFRESH_HEADER_NAME, refresh_text.as_str())) -/// .cookie( -/// actix_web::cookie::Cookie::build(JWT_COOKIE_NAME, jwt_text.as_str()) -/// .finish() -/// ) -/// .cookie( -/// actix_web::cookie::Cookie::build(REFRESH_COOKIE_NAME, refresh_text.as_str()) -/// .finish() -/// ) -/// .finish() -/// } -/// ``` -pub static REFRESH_COOKIE_NAME: &str = "ACX-Refresh"; - -/// 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 + std::fmt::Debug + 'static -{ - /// Unique token identifier - fn jti(&self) -> uuid::Uuid; - - /// Login, email or other identifier - fn subject(&self) -> &str; -} - -/// Internal claims which allows to extend tokens pair livetime -/// -/// After encoding it can be used as HTTP token send to endpoint, decoded and extend pair livetime. -/// It's always created while calling [SessionStorage::store]. If there's any extractor for refresh -/// you can use this structure as guard for an endpoint. -/// -/// Example: -/// -/// ``` -/// use actix_web::{get, HttpResponse}; -/// use actix_web::web::Data; -/// use actix_jwt_session::*; -/// -/// #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -/// pub struct AppClaims { id: uuid::Uuid, sub: String } -/// impl actix_jwt_session::Claims for AppClaims { -/// fn jti(&self) -> uuid::Uuid { self.id } -/// fn subject(&self) -> &str { &self.sub } -/// } -/// -/// #[get("/session/refresh")] -/// async fn refresh_session( -/// auth: Authenticated, -/// storage: Data, -/// ) -> HttpResponse { -/// let storage = storage.into_inner(); -/// storage.refresh::(auth.refresh_jti).await.unwrap(); -/// HttpResponse::Ok().json(&*auth) -/// } -/// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RefreshToken { - /// date and time when token was created - #[serde(rename = "iat")] - pub issues_at: OffsetDateTime, - - /// related JWT unique identifier - #[serde(rename = "sub")] - access_jti: String, - - /// JWT lifetime - pub access_ttl: JwtTtl, - - /// this token unique identifier - pub refresh_jti: uuid::Uuid, - - /// this token lifetime - pub refresh_ttl: RefreshTtl, - - // REQUIRED - /// this token lifetime as integer - /// (this field is required by standard) - #[serde(rename = "exp")] - pub expiration_time: u64, - - /// time before which token is not validate - /// (this field is required by standard and always set `0`) - #[serde(rename = "nbf")] - pub not_before: u64, - - /// target audience - /// (this field is required by standard) - #[serde(rename = "aud")] - - /// who created this token - /// (this field is required by standard) - pub audience: String, - #[serde(rename = "iss")] - pub issuer: String, -} - -impl PartialEq for RefreshToken { - fn eq(&self, o: &Self) -> bool { - self.access_jti == o.access_jti - && self.refresh_jti == o.refresh_jti - && self.refresh_ttl == o.refresh_ttl - && self.expiration_time == o.expiration_time - && self.not_before == o.not_before - && self.audience == o.audience - && self.issuer == o.issuer - } -} - -impl RefreshToken { - pub fn is_access_valid(&self) -> bool { - self.issues_at + self.access_ttl.0 >= OffsetDateTime::now_utc() - } - - pub fn is_refresh_valid(&self) -> bool { - self.issues_at + self.refresh_ttl.0 >= OffsetDateTime::now_utc() - } - - pub fn access_jti(&self) -> uuid::Uuid { - Uuid::parse_str(&self.access_jti).unwrap() - } -} - -impl Claims for RefreshToken { - fn jti(&self) -> uuid::Uuid { - self.refresh_jti - } - fn subject(&self) -> &str { - "refresh-token" - } -} - -/// JSON Web Token and internally created refresh token. -/// -/// Both should be encoded using [Authenticated::encode] and added to response as cookie, header -/// or in body. -pub struct Pair { - /// Access token in form of JWT decrypted token - pub jwt: Authenticated, - /// Refresh token in form of JWT decrypted token - pub refresh: Authenticated, -} - -/// Session related errors -#[derive(Debug, thiserror::Error, PartialEq, Clone, Copy)] -pub enum Error { - #[error("Failed to obtain redis connection")] - RedisConn, - #[error("Record not found")] - NotFound, - #[error("Record malformed")] - RecordMalformed, - #[error("Invalid session")] - InvalidSession, - #[error("Claims can't be loaded")] - LoadError, - #[error("Storage claims and given claims are different")] - DontMatch, - #[error("Given token in invalid. Can't decode claims")] - CantDecode, - #[error("No http authentication header")] - NoAuthHeader, - #[error("Failed to serialize claims")] - SerializeFailed, - #[error("Unable to write claims to storage")] - WriteFailed, - #[error("Access token expired")] - JWTExpired, -} - -impl actix_web::ResponseError for Error { - fn status_code(&self) -> actix_web::http::StatusCode { - match self { - Self::RedisConn => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, - _ => actix_web::http::StatusCode::UNAUTHORIZED, - } - } - - fn error_response(&self) -> actix_web::HttpResponse { - HttpResponse::build(self.status_code()).body("") - } -} - -/// 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 AppClaims { id: uuid::Uuid, sub: String } -/// impl actix_jwt_session::Claims for AppClaims { -/// 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) -> HttpResponse { -/// let encoded = session.encode().unwrap(); // JWT as encrypted string -/// HttpResponse::Ok().finish() -/// } -/// ``` -#[derive(Clone)] -pub struct Authenticated { - pub claims: Arc, - pub jwt_encoding_key: Arc, - pub algorithm: Algorithm, -} - -impl std::ops::Deref for Authenticated { - type Target = T; - - fn deref(&self) -> &Self::Target { - &self.claims - } -} - -impl Authenticated { - /// Encode claims as JWT encrypted string - pub fn encode(&self) -> Result { - encode( - &jsonwebtoken::Header::new(self.algorithm), - &*self.claims, - &self.jwt_encoding_key, - ) - } -} - -impl FromRequest for Authenticated { - type Error = actix_web::error::Error; - type Future = std::future::Ready>; - - fn from_request( - req: &actix_web::HttpRequest, - _payload: &mut actix_web::dev::Payload, - ) -> Self::Future { - let value = req - .extensions_mut() - .get::>() - .map(Clone::clone); - std::future::ready(value.ok_or_else(|| Error::NotFound.into())) - } -} - -/// 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) -> HttpResponse { -/// if let Some(session) = session.into_option() { -/// // handle authenticated request -/// } -/// HttpResponse::Ok().finish() -/// } -/// ``` -pub struct MaybeAuthenticated(Option>); - -impl MaybeAuthenticated { - pub fn is_authenticated(&self) -> bool { - 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> { - self.0 - } -} - -impl std::ops::Deref for MaybeAuthenticated { - type Target = Option>; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl FromRequest for MaybeAuthenticated { - type Error = actix_web::error::Error; - type Future = std::future::Ready>; - - fn from_request( - req: &actix_web::HttpRequest, - _payload: &mut actix_web::dev::Payload, - ) -> Self::Future { - let value = req - .extensions_mut() - .get::>() - .map(Clone::clone); - std::future::ready(Ok(MaybeAuthenticated(value))) - } -} - -/// 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 { - /// 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_by_jti(self: Arc, jti: &[u8]) -> Result, 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, - jwt_jti: &[u8], - refresh_jti: &[u8], - bytes: &[u8], - exp: 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, jti: &[u8]) -> Result<(), Error>; -} - -/// Allow to save, read and remove session from storage. -#[derive(Clone)] -pub struct SessionStorage { - storage: Arc, - jwt_encoding_key: Arc, - algorithm: Algorithm, -} - -impl std::ops::Deref for SessionStorage { - type Target = Arc; - - fn deref(&self) -> &Self::Target { - &self.storage - } -} - -#[doc(hidden)] -/// This structure is saved to session storage (for example Redis) -/// It's internal structure and should not be used unless you plan to create new session storage -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct SessionRecord { - refresh_jti: uuid::Uuid, - jwt_jti: uuid::Uuid, - refresh_token: String, - jwt: String, -} - -impl SessionRecord { - /// Create new record from user claims and generated refresh token - /// - /// Both claims are serialized to text and saved as a string - fn new(claims: ClaimsType, refresh: RefreshToken) -> Result { - let refresh_jti = claims.jti(); - let jwt_jti = refresh.refresh_jti; - let refresh_token = serde_json::to_string(&refresh).map_err(|e| { - #[cfg(feature = "use-tracing")] - tracing::debug!("Failed to serialize Refresh Token to construct pair: {e:?}"); - Error::SerializeFailed - })?; - let jwt = serde_json::to_string(&claims).map_err(|e| { - #[cfg(feature = "use-tracing")] - tracing::debug!("Failed to serialize JWT from to construct pair {e:?}"); - Error::SerializeFailed - })?; - Ok(Self { - refresh_jti, - jwt_jti, - refresh_token, - jwt, - }) - } - - /// Deserialize loaded refresh token - fn refresh_token(&self) -> Result { - serde_json::from_str(&self.refresh_token).map_err(|e| { - #[cfg(feature = "use-tracing")] - tracing::debug!("Failed to deserialize refresh token from pair: {e:?}"); - Error::RecordMalformed - }) - } - - /// Deserialize field content to structure - fn from_field(s: &str) -> Result { - serde_json::from_str(s).map_err(|e| { - #[cfg(feature = "use-tracing")] - tracing::debug!( - "Failed to deserialize {} for pair: {e:?}", - std::any::type_name::() - ); - Error::RecordMalformed - }) - } - - /// Serialize refresh token in this record and replace field with generated text - fn set_refresh_token(&mut self, mut refresh: RefreshToken) -> Result<(), Error> { - refresh.expiration_time = refresh.refresh_ttl.0.as_seconds_f64() as u64; - let refresh_token = serde_json::to_string(&refresh).map_err(|e| { - #[cfg(feature = "use-tracing")] - tracing::debug!("Failed to serialize refresh token for pair: {e:?}"); - Error::SerializeFailed - })?; - self.refresh_token = refresh_token; - Ok(()) - } -} - -impl SessionStorage { - /// Abstraction layer over database holding tokens information - /// - /// It allows read/write/update/delete operation on tokens - pub fn new( - storage: Arc, - jwt_encoding_key: Arc, - algorithm: Algorithm, - ) -> Self { - Self { - storage, - jwt_encoding_key, - algorithm, - } - } - - /// 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 find_jwt(&self, jti: uuid::Uuid) -> Result { - let record = self.load_pair_by_jwt(jti).await?; - let refresh_token = record.refresh_token()?; - if std::any::type_name::() == std::any::type_name::() { - SessionRecord::from_field(&record.refresh_token) - } else { - if !refresh_token.is_access_valid() { - #[cfg(feature = "use-tracing")] - tracing::debug!("JWT expired"); - return Err(Error::JWTExpired); - } - SessionRecord::from_field(&record.jwt) - } - } - - /// Changes [RefreshToken::issues_at] allowing Claims and RefreshToken to be accessible longer - /// - /// Examples: - /// - /// ``` - /// use actix_jwt_session::SessionStorage; - /// use actix_web::{Error, HttpResponse}; - /// - /// #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] - /// pub struct AppClaims { id: uuid::Uuid, sub: String } - /// impl actix_jwt_session::Claims for AppClaims { - /// fn jti(&self) -> uuid::Uuid { self.id } - /// fn subject(&self) -> &str { &self.sub } - /// } - /// - /// async fn extend_tokens_lifetime( - /// session_storage: SessionStorage, - /// jti: uuid::Uuid - /// ) -> Result { - /// session_storage.refresh::(jti).await?; - /// Ok(HttpResponse::Ok().finish()) - /// } - /// ``` - pub async fn refresh( - &self, - refresh_jti: uuid::Uuid, - ) -> Result, Error> { - let mut record = self.load_pair_by_refresh(refresh_jti).await?; - let mut refresh_token = record.refresh_token()?; - let ttl = refresh_token.refresh_ttl; - refresh_token.issues_at = OffsetDateTime::now_utc(); - record.set_refresh_token(refresh_token)?; - self.store_pair(record.clone(), ttl).await?; - - let claims = SessionRecord::from_field::(&record.jwt)?; - let refresh = SessionRecord::from_field::(&record.refresh_token)?; - Ok(Pair { - jwt: Authenticated { - claims: Arc::new(claims), - jwt_encoding_key: self.jwt_encoding_key.clone(), - algorithm: self.algorithm, - }, - refresh: Authenticated { - claims: Arc::new(refresh), - jwt_encoding_key: self.jwt_encoding_key.clone(), - algorithm: self.algorithm, - }, - }) - } - - /// 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, - access_ttl: JwtTtl, - refresh_ttl: RefreshTtl, - ) -> Result, Error> { - let now = OffsetDateTime::now_utc(); - let refresh = RefreshToken { - refresh_jti: uuid::Uuid::new_v4(), - refresh_ttl, - access_jti: claims.jti().hyphenated().to_string(), - access_ttl, - issues_at: now, - expiration_time: refresh_ttl.0.as_seconds_f64() as u64, - issuer: claims.jti().hyphenated().to_string(), - not_before: 0, - audience: claims.subject().to_string(), - }; - - let record = SessionRecord::new(claims.clone(), refresh.clone())?; - self.store_pair(record, refresh_ttl).await?; - - Ok(Pair { - jwt: Authenticated { - claims: Arc::new(claims), - jwt_encoding_key: self.jwt_encoding_key.clone(), - algorithm: self.algorithm, - }, - refresh: Authenticated { - claims: Arc::new(refresh), - 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> { - let record = self.load_pair_by_jwt(jti).await?; - - self.storage - .clone() - .remove_by_jti(record.refresh_jti.as_bytes()) - .await?; - self.storage - .clone() - .remove_by_jti(record.jwt_jti.as_bytes()) - .await?; - - Ok(()) - } - - /// Write to storage tokens pair as [SessionRecord] - /// This operation allows to load pair using JWT ID and Refresh Token ID - async fn store_pair( - &self, - record: SessionRecord, - refresh_ttl: RefreshTtl, - ) -> Result<(), Error> { - let value = bincode::serialize(&record).map_err(|e| { - #[cfg(feature = "use-tracing")] - tracing::debug!("Serialize pair to bytes failed: {e:?}"); - Error::SerializeFailed - })?; - - self.storage - .clone() - .set_by_jti( - record.jwt_jti.as_bytes(), - record.refresh_jti.as_bytes(), - &value, - refresh_ttl.0, - ) - .await?; - - Ok(()) - } - - /// Load [SessionRecord] as tokens pair from storage using JWT ID (jti) - async fn load_pair_by_jwt(&self, jti: Uuid) -> Result { - self.storage - .clone() - .get_by_jti(jti.as_bytes()) - .await - .and_then(|bytes| { - bincode::deserialize(&bytes).map_err(|e| { - #[cfg(feature = "use-tracing")] - tracing::debug!("Deserialize pair while loading for JWT ID failed: {e:?}"); - Error::RecordMalformed - }) - }) - } - - /// Load [SessionRecord] as tokens pair from storage using Refresh ID (jti) - async fn load_pair_by_refresh(&self, jti: Uuid) -> Result { - self.storage - .clone() - .get_by_jti(jti.as_bytes()) - .await - .and_then(|bytes| { - bincode::deserialize(&bytes).map_err(|e| { - #[cfg(feature = "use-tracing")] - tracing::debug!("Deserialize pair while loading for refresh id failed: {e:?}"); - Error::RecordMalformed - }) - }) - } -} - -mod extractors; -pub use extractors::*; - -/// Load or generate new Ed25519 signing keys. -/// -/// [JwtSigningKeys::load_or_create] should be called only once at the boot of the server. -/// -/// If there's any issue during generating new keys or loading exiting one application will panic. -/// -/// Examples: -/// -/// ```rust -/// use actix_jwt_session::*; -/// -/// pub fn boot_server() { -/// let keys = JwtSigningKeys::load_or_create(); -/// } -/// ``` -pub struct JwtSigningKeys { - pub encoding_key: EncodingKey, - pub decoding_key: DecodingKey, -} - -impl JwtSigningKeys { - /// Loads signing keys from `./config` directory or creates new pair and save it to directory. - /// - /// Pair is composed of encode key and decode key saved in `./config/jwt-encoding.bin` and `./config/jwt-decoding.bin` - /// written as binary file. - /// - /// Decode key can be transform to base64 and shared with clients if this is required. - /// - /// Files must be shared between restarts otherwise all old sessions will be invalidated. - pub fn load_or_create() -> Self { - match Self::load_from_files() { - Ok(s) => s, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - Self::generate(true).expect("Generating new jwt signing keys must succeed") - } - Err(e) => panic!("Failed to load or generate jwt signing keys: {:?}", e), - } - } - - pub fn generate(save: bool) -> Result> { - use jsonwebtoken::*; - use ring::rand::SystemRandom; - use ring::signature::{Ed25519KeyPair, KeyPair}; - - let doc = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new())?; - let keypair = Ed25519KeyPair::from_pkcs8(doc.as_ref())?; - let encoding_key = EncodingKey::from_ed_der(doc.as_ref()); - let decoding_key = DecodingKey::from_ed_der(keypair.public_key().as_ref()); - - if save { - std::fs::write("./config/jwt-encoding.bin", doc.as_ref()).unwrap_or_else(|e| { - panic!("Failed to write ./config/jwt-encoding.bin: {:?}", e); - }); - std::fs::write("./config/jwt-decoding.bin", keypair.public_key()).unwrap_or_else(|e| { - panic!("Failed to write ./config/jwt-decoding.bin: {:?}", e); - }); - } - - Ok(JwtSigningKeys { - encoding_key, - decoding_key, - }) - } - - pub fn load_from_files() -> std::io::Result { - use jsonwebtoken::*; - use std::io::Read; - - let mut buf = Vec::new(); - let mut e = std::fs::File::open("./config/jwt-encoding.bin")?; - e.read_to_end(&mut buf).unwrap_or_else(|e| { - panic!("Failed to read jwt encoding key: {:?}", e); - }); - let encoding_key: EncodingKey = EncodingKey::from_ed_der(&buf); - - let mut buf = Vec::new(); - let mut e = std::fs::File::open("./config/jwt-decoding.bin")?; - e.read_to_end(&mut buf).unwrap_or_else(|e| { - panic!("Failed to read jwt decoding key: {:?}", e); - }); - let decoding_key = DecodingKey::from_ed_der(&buf); - Ok(Self { - encoding_key, - decoding_key, - }) - } -} - -#[macro_export] -macro_rules! bad_ttl { - ($ttl: expr, $min: expr, $panic_msg: expr) => { - if $ttl < $min { - #[cfg(feature = "use-tracing")] - tracing::warn!( - "Expiration time is bellow 1s. This is not allowed for redis server. Overriding!" - ); - if cfg!(feature = "panic-bad-ttl") { - panic!($panic_msg); - } else if cfg!(feature = "override-bad-ttl") { - $ttl = $min; - } - } - }; -} - -mod middleware; -pub use middleware::*; - -#[cfg(feature = "redis")] -mod redis_adapter; -#[cfg(feature = "redis")] -pub use redis_adapter::*; -#[cfg(feature = "hashing")] -mod hashing; -#[cfg(feature = "hashing")] -pub use hashing::*; diff --git a/crates/actix-jwt-session/src/middleware.rs b/crates/actix-jwt-session/src/middleware.rs deleted file mode 100644 index 2deb0d9..0000000 --- a/crates/actix-jwt-session/src/middleware.rs +++ /dev/null @@ -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 { - pub(crate) jwt_encoding_key: Arc, - pub(crate) jwt_decoding_key: Arc, - pub(crate) algorithm: Algorithm, - pub(crate) storage: Option, - pub(crate) jwt_extractors: Vec>>, - pub(crate) refresh_extractors: Vec>>, -} -impl SessionMiddlewareBuilder { - #[doc(hidden)] - pub(crate) fn new( - jwt_encoding_key: Arc, - jwt_decoding_key: Arc, - 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::::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::::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::::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::::new(name))); - self - } - - /// Builds middleware factory and returns session storage with factory - pub fn finish(self) -> (SessionStorage, SessionMiddlewareFactory) { - 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() { -/// // 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::::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 { - pub(crate) jwt_encoding_key: Arc, - pub(crate) jwt_decoding_key: Arc, - pub(crate) algorithm: Algorithm, - pub(crate) storage: SessionStorage, - pub(crate) jwt_extractors: Arc>>>, - pub(crate) refresh_extractors: Arc>>>, -} - -impl SessionMiddlewareFactory { - pub fn build( - jwt_encoding_key: Arc, - jwt_decoding_key: Arc, - algorithm: Algorithm, - ) -> SessionMiddlewareBuilder { - SessionMiddlewareBuilder::new(jwt_encoding_key, jwt_decoding_key, algorithm) - } -} - -impl Transform for SessionMiddlewareFactory -where - S: Service, Error = actix_web::Error> + 'static, - ClaimsType: Claims, -{ - type Response = ServiceResponse; - type Error = actix_web::Error; - type Transform = SessionMiddleware; - type InitError = (); - type Future = Ready>; - - 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 -where - ClaimsType: Claims, -{ - pub(crate) service: Rc, - pub(crate) jwt_encoding_key: Arc, - pub(crate) jwt_decoding_key: Arc, - pub(crate) algorithm: Algorithm, - pub(crate) storage: SessionStorage, - pub(crate) jwt_extractors: Arc>>>, - pub(crate) refresh_extractors: Arc>>>, -} - -impl SessionMiddleware { - async fn extract_token( - req: &mut ServiceRequest, - jwt_encoding_key: Arc, - jwt_decoding_key: Arc, - algorithm: Algorithm, - storage: SessionStorage, - extractors: &[Box>], - ) -> 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 Service for SessionMiddleware -where - ClaimsType: Claims, - S: Service, Error = actix_web::Error> + 'static, -{ - type Response = ServiceResponse; - type Error = actix_web::Error; - type Future = LocalBoxFuture<'static, Result>; - - 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() - } -} diff --git a/crates/actix-jwt-session/src/redis_adapter.rs b/crates/actix-jwt-session/src/redis_adapter.rs deleted file mode 100644 index 6500b91..0000000 --- a/crates/actix-jwt-session/src/redis_adapter.rs +++ /dev/null @@ -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 { - pool: redis_async_pool::RedisPool, - _claims_type_marker: PhantomData, -} - -impl RedisStorage { - pub fn new(pool: redis_async_pool::RedisPool) -> Self { - Self { - pool, - _claims_type_marker: Default::default(), - } - } -} - -#[async_trait::async_trait(?Send)] -impl TokenStorage for RedisStorage -where - ClaimsType: Claims, -{ - async fn get_by_jti(self: Arc, 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.get::<_, Vec>(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, - 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, 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 SessionMiddlewareBuilder { - #[must_use] - pub fn with_redis_pool(mut self, pool: redis_async_pool::RedisPool) -> Self { - let storage = Arc::new(RedisStorage::::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) { - 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::::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); - } -} diff --git a/crates/actix-jwt-session/tests/ensure_redis_flow.rs b/crates/actix-jwt-session/tests/ensure_redis_flow.rs deleted file mode 100644 index 24db5c2..0000000 --- a/crates/actix-jwt-session/tests/ensure_redis_flow.rs +++ /dev/null @@ -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::::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, - claims: Json, - jwt_ttl: Data, - refresh_ttl: Data, -) -> Result { - 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, auth: Authenticated) -> HttpResponse { - let store = store.into_inner(); - store.erase::(auth.id).await.unwrap(); - HttpResponse::Ok().finish() -} - -#[get("/session/info")] -async fn session(auth: Authenticated) -> HttpResponse { - HttpResponse::Ok().json(&*auth) -} - -#[get("/session/refresh")] -async fn refresh_session( - auth: Authenticated, - storage: Data, -) -> 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() -} - -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) { - let err = res - .expect_err("Must be unauthorized") - .as_error::() - .expect("Must be authorization error") - .clone(); - assert_eq!(err, actix_jwt_session::Error::LoadError); -} diff --git a/crates/oswilno-session/Cargo.toml b/crates/oswilno-session/Cargo.toml index 87ce57a..1b1a53b 100644 --- a/crates/oswilno-session/Cargo.toml +++ b/crates/oswilno-session/Cargo.toml @@ -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" diff --git a/crates/oswilno-view/Cargo.toml b/crates/oswilno-view/Cargo.toml index c7eac0e..803c0d6 100644 --- a/crates/oswilno-view/Cargo.toml +++ b/crates/oswilno-view/Cargo.toml @@ -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 = "*" }