Clear workspace
This commit is contained in:
parent
6d721886d0
commit
0b145bec5d
43
Cargo.lock
generated
43
Cargo.lock
generated
@ -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",
|
||||
|
@ -6,6 +6,5 @@ members = [
|
||||
'./crates/oswilno-parking-space',
|
||||
'./crates/migration',
|
||||
'./crates/oswilno-actix-admin',
|
||||
'./crates/actix-jwt-session',
|
||||
]
|
||||
resolver = "2"
|
||||
|
@ -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"
|
@ -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
|
@ -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())
|
||||
}
|
||||
}
|
@ -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
@ -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()
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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"
|
||||
|
@ -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 = "*" }
|
||||
|
Loading…
Reference in New Issue
Block a user