diff --git a/.github/workflows/ci-master.yml b/.github/workflows/ci-post-merge.yml similarity index 93% rename from .github/workflows/ci-master.yml rename to .github/workflows/ci-post-merge.yml index 995a7310b..2de0ee349 100644 --- a/.github/workflows/ci-master.yml +++ b/.github/workflows/ci-post-merge.yml @@ -1,4 +1,4 @@ -name: CI (master only) +name: CI (post-merge) on: push: @@ -34,6 +34,9 @@ jobs: profile: minimal override: true + - name: Install cargo-hack + uses: taiki-e/install-action@cargo-hack + - name: Generate Cargo.lock uses: actions-rs/cargo@v1 with: @@ -41,12 +44,6 @@ jobs: - name: Cache Dependencies uses: Swatinem/rust-cache@v1.2.0 - - name: Install cargo-hack - uses: actions-rs/cargo@v1 - with: - command: install - args: cargo-hack - - name: check minimal uses: actions-rs/cargo@v1 with: { command: ci-min } @@ -92,6 +89,9 @@ jobs: profile: minimal override: true + - name: Install cargo-hack + uses: taiki-e/install-action@cargo-hack + - name: Generate Cargo.lock uses: actions-rs/cargo@v1 with: @@ -99,12 +99,6 @@ jobs: - name: Cache Dependencies uses: Swatinem/rust-cache@v1.2.0 - - name: Install cargo-hack - uses: actions-rs/cargo@v1 - with: - command: install - args: cargo-hack - - name: check minimal uses: actions-rs/cargo@v1 with: { command: ci-min } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da39918c0..c9cb5522f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: target: - { name: Linux, os: ubuntu-latest, triple: x86_64-unknown-linux-gnu } version: - - 1.54.0 # MSRV + - 1.57 # MSRV - stable name: ${{ matrix.target.name }} / ${{ matrix.version }} @@ -42,6 +42,9 @@ jobs: profile: minimal override: true + - name: Install cargo-hack + uses: taiki-e/install-action@cargo-hack + - name: Generate Cargo.lock uses: actions-rs/cargo@v1 with: @@ -49,12 +52,6 @@ jobs: - name: Cache Dependencies uses: Swatinem/rust-cache@v1.2.0 - - name: Install cargo-hack - uses: actions-rs/cargo@v1 - with: - command: install - args: cargo-hack - - name: check minimal uses: actions-rs/cargo@v1 with: { command: ci-min } @@ -85,7 +82,7 @@ jobs: - { name: macOS, os: macos-latest, triple: x86_64-apple-darwin } - { name: Windows, os: windows-latest, triple: x86_64-pc-windows-msvc } version: - - 1.54.0 # MSRV + - 1.57 # MSRV - stable name: ${{ matrix.target.name }} / ${{ matrix.version }} @@ -101,6 +98,9 @@ jobs: profile: minimal override: true + - name: Install cargo-hack + uses: taiki-e/install-action@cargo-hack + - name: Generate Cargo.lock uses: actions-rs/cargo@v1 with: @@ -108,12 +108,6 @@ jobs: - name: Cache Dependencies uses: Swatinem/rust-cache@v1.2.0 - - name: Install cargo-hack - uses: actions-rs/cargo@v1 - with: - command: install - args: cargo-hack - - name: check minimal uses: actions-rs/cargo@v1 with: { command: ci-min } diff --git a/Cargo.toml b/Cargo.toml index eaf622615..9a953a3ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,9 +10,6 @@ members = [ "actix-web-httpauth", ] -# TODO: move this example to examples repo -# "actix-protobuf/examples/prost-example", - [patch.crates-io] actix-cors = { path = "./actix-cors" } actix-identity = { path = "./actix-identity" } diff --git a/README.md b/README.md index 8cfb562d3..662ed8fe1 100644 --- a/README.md +++ b/README.md @@ -25,21 +25,22 @@ These crates are provided by the community. -| Crate | | | -| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| [actix-web-lab] | [![crates.io](https://img.shields.io/crates/v/actix-web-lab?label=latest)](https://crates.io/crates/actix-web-lab) [![dependency status](https://deps.rs/crate/actix-web-lab/0.15.0/status.svg)](https://deps.rs/crate/actix-web-lab/0.15.0) | Experimental extractors, middleware, and other extras for possible inclusion in Actix Web. | -| [actix-form-data] | [![crates.io](https://img.shields.io/crates/v/actix-form-data?label=latest)](https://crates.io/crates/actix-form-data) [![dependency status](https://deps.rs/crate/actix-form-data/0.6.2/status.svg)](https://deps.rs/crate/actix-form-data/0.6.2) | Rate-limiting backed by form-data. | -| [actix-governor] | [![crates.io](https://img.shields.io/crates/v/actix-governor?label=latest)](https://crates.io/crates/actix-governor) [![dependency status](https://deps.rs/crate/actix-governor/0.3.0/status.svg)](https://deps.rs/crate/actix-governor/0.3.0) | Rate-limiting backed by governor. | -| [actix-casbin] | [![crates.io](https://img.shields.io/crates/v/actix-casbin?label=latest)](https://crates.io/crates/actix-casbin) [![dependency status](https://deps.rs/crate/actix-casbin/0.4.2/status.svg)](https://deps.rs/crate/actix-casbin/0.4.2) | Authorization library that supports access control models like ACL, RBAC & ABAC. | -| [actix-ip-filter] | [![crates.io](https://img.shields.io/crates/v/actix-ip-filter?label=latest)](https://crates.io/crates/actix-ip-filter) [![dependency status](https://deps.rs/crate/actix-ip-filter/0.3.1/status.svg)](https://deps.rs/crate/actix-ip-filter/0.3.1) | IP address filter. Supports glob patterns. | -| [actix-web-static-files] | [![crates.io](https://img.shields.io/crates/v/actix-web-static-files?label=latest)](https://crates.io/crates/actix-web-static-files) [![dependency status](https://deps.rs/crate/actix-web-static-files/4.0.0/status.svg)](https://deps.rs/crate/actix-web-static-files/4.0.0) | Static files as embedded resources. | -| [actix-web-grants] | [![crates.io](https://img.shields.io/crates/v/actix-web-grants?label=latest)](https://crates.io/crates/actix-web-grants) [![dependency status](https://deps.rs/crate/actix-web-grants/3.0.0-beta.6/status.svg)](https://deps.rs/crate/actix-web-grants/3.0.0-beta.6) | Extension for validating user authorities. | -| [aliri_actix] | [![crates.io](https://img.shields.io/crates/v/aliri_actix?label=latest)](https://crates.io/crates/aliri_actix) [![dependency status](https://deps.rs/crate/aliri_actix/0.6.0/status.svg)](https://deps.rs/crate/aliri_actix/0.6.0) | Endpoint authorization and authentication using scoped OAuth2 JWT tokens. | -| [actix-web-flash-messages] | [![crates.io](https://img.shields.io/crates/v/actix-web-flash-messages?label=latest)](https://crates.io/crates/actix-web-flash-messages) [![dependency status](https://deps.rs/crate/actix-web-flash-messages/0.3.2/status.svg)](https://deps.rs/crate/actix-web-flash-messages/0.3.2) | Support for flash messages/one-time notifications in `actix-web`. | -| [awmp] | [![crates.io](https://img.shields.io/crates/v/awmp?label=latest)](https://crates.io/crates/awmp) [![dependency status](https://deps.rs/crate/awmp/0.8.1/status.svg)](https://deps.rs/crate/awmp/0.8.1) | An easy to use wrapper around multipart fields for Actix Web. | -| [tracing-actix-web] | [![crates.io](https://img.shields.io/crates/v/tracing-actix-web?label=latest)](https://crates.io/crates/tracing-actix-web) [![dependency status](https://deps.rs/crate/tracing-actix-web/0.5.1/status.svg)](https://deps.rs/crate/tracing-actix-web/0.5.1) | A middleware to collect telemetry data from applications built on top of the actix-web framework. | -| [actix-ws] | [![crates.io](https://img.shields.io/crates/v/actix-ws?label=latest)](https://crates.io/crates/actix-ws) [![dependency status](https://deps.rs/crate/actix-ws/0.2.5/status.svg)](https://deps.rs/crate/actix-ws/0.2.5) | Actor-less Websockets for the Actix Runtime. | -| [actix-hash] | [![crates.io](https://img.shields.io/crates/v/actix-hash?label=latest)](https://crates.io/crates/actix-hash) [![dependency status](https://deps.rs/crate/actix-hash/0.3.0/status.svg)](https://deps.rs/crate/actix-hash/0.3.0) | Hashing utilities for Actix Web. | +| Crate | | | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| [actix-web-lab] | [![crates.io](https://img.shields.io/crates/v/actix-web-lab?label=latest)][actix-web-lab] [![dependency status](https://deps.rs/crate/actix-web-lab/0.16.4/status.svg)](https://deps.rs/crate/actix-web-lab/0.16.4) | Experimental extractors, middleware, and other extras for possible inclusion in Actix Web. | +| [actix-multipart-extract] | [![crates.io](https://img.shields.io/crates/v/actix-multipart-extract?label=latest)][actix-multipart-extract] [![dependency status](https://deps.rs/crate/actix-multipart-extract/0.1.4/status.svg)](https://deps.rs/crate/actix-multipart-extract/0.1.4) | Better multipart form support for Actix Web. | +| [actix-form-data] | [![crates.io](https://img.shields.io/crates/v/actix-form-data?label=latest)][actix-form-data] [![dependency status](https://deps.rs/crate/actix-form-data/0.6.2/status.svg)](https://deps.rs/crate/actix-form-data/0.6.2) | Rate-limiting backed by form-data. | +| [actix-governor] | [![crates.io](https://img.shields.io/crates/v/actix-governor?label=latest)][actix-governor] [![dependency status](https://deps.rs/crate/actix-governor/0.3.0/status.svg)](https://deps.rs/crate/actix-governor/0.3.0) | Rate-limiting backed by governor. | +| [actix-casbin] | [![crates.io](https://img.shields.io/crates/v/actix-casbin?label=latest)][actix-casbin] [![dependency status](https://deps.rs/crate/actix-casbin/0.4.2/status.svg)](https://deps.rs/crate/actix-casbin/0.4.2) | Authorization library that supports access control models like ACL, RBAC & ABAC. | +| [actix-ip-filter] | [![crates.io](https://img.shields.io/crates/v/actix-ip-filter?label=latest)][actix-ip-filter] [![dependency status](https://deps.rs/crate/actix-ip-filter/0.3.1/status.svg)](https://deps.rs/crate/actix-ip-filter/0.3.1) | IP address filter. Supports glob patterns. | +| [actix-web-static-files] | [![crates.io](https://img.shields.io/crates/v/actix-web-static-files?label=latest)][actix-web-static-files] [![dependency status](https://deps.rs/crate/actix-web-static-files/4.0.0/status.svg)](https://deps.rs/crate/actix-web-static-files/4.0.0) | Static files as embedded resources. | +| [actix-web-grants] | [![crates.io](https://img.shields.io/crates/v/actix-web-grants?label=latest)][actix-web-grants] [![dependency status](https://deps.rs/crate/actix-web-grants/3.0.1/status.svg)](https://deps.rs/crate/actix-web-grants/3.0.1) | Extension for validating user authorities. | +| [aliri_actix] | [![crates.io](https://img.shields.io/crates/v/aliri_actix?label=latest)][aliri_actix] [![dependency status](https://deps.rs/crate/aliri_actix/0.7.0/status.svg)](https://deps.rs/crate/aliri_actix/0.7.0) | Endpoint authorization and authentication using scoped OAuth2 JWT tokens. | +| [actix-web-flash-messages] | [![crates.io](https://img.shields.io/crates/v/actix-web-flash-messages?label=latest)][actix-web-flash-messages] [![dependency status](https://deps.rs/crate/actix-web-flash-messages/0.4.1/status.svg)](https://deps.rs/crate/actix-web-flash-messages/0.4.1) | Support for flash messages/one-time notifications in `actix-web`. | +| [awmp] | [![crates.io](https://img.shields.io/crates/v/awmp?label=latest)][awmp] [![dependency status](https://deps.rs/crate/awmp/0.8.1/status.svg)](https://deps.rs/crate/awmp/0.8.1) | An easy to use wrapper around multipart fields for Actix Web. | +| [tracing-actix-web] | [![crates.io](https://img.shields.io/crates/v/tracing-actix-web?label=latest)][tracing-actix-web] [![dependency status](https://deps.rs/crate/tracing-actix-web/0.6.0/status.svg)](https://deps.rs/crate/tracing-actix-web/0.6.0) | A middleware to collect telemetry data from applications built on top of the actix-web framework. | +| [actix-ws] | [![crates.io](https://img.shields.io/crates/v/actix-ws?label=latest)][actix-ws] [![dependency status](https://deps.rs/crate/actix-ws/0.2.5/status.svg)](https://deps.rs/crate/actix-ws/0.2.5) | Actor-less WebSockets for the Actix Runtime. | +| [actix-hash] | [![crates.io](https://img.shields.io/crates/v/actix-hash?label=latest)][actix-hash] [![dependency status](https://deps.rs/crate/actix-hash/0.4.0/status.svg)](https://deps.rs/crate/actix-hash/0.4.0) | Hashing utilities for Actix Web. | To add a crate to this list, submit a pull request. @@ -48,23 +49,24 @@ To add a crate to this list, submit a pull request. [actix]: https://github.com/actix/actix [actix web]: https://github.com/actix/actix-web [actix-extras]: https://github.com/actix/actix-extras -[actix-cors]: actix-cors -[actix-identity]: actix-identity -[actix-limitation]: actix-limitation -[actix-protobuf]: actix-protobuf -[actix-redis]: actix-redis -[actix-session]: actix-session -[actix-web-httpauth]: actix-web-httpauth -[actix-web-lab]: https://github.com/robjtede/actix-web-lab/tree/main/actix-web-lab -[actix-form-data]: https://git.asonix.dog/asonix/actix-form-data -[actix-casbin]: https://github.com/casbin-rs/actix-casbin -[actix-ip-filter]: https://github.com/jhen0409/actix-ip-filter -[actix-web-static-files]: https://github.com/kilork/actix-web-static-files -[actix-web-grants]: https://github.com/DDtKey/actix-web-grants -[actix-web-flash-messages]: https://github.com/LukeMathWalker/actix-web-flash-messages -[actix-governor]: https://github.com/AaronErhardt/actix-governor -[aliri_actix]: https://github.com/neoeinstein/aliri -[awmp]: https://github.com/kardeiz/awmp -[tracing-actix-web]: https://github.com/LukeMathWalker/tracing-actix-web -[actix-ws]: https://git.asonix.dog/asonix/actix-actorless-websockets -[actix-hash]: https://github.com/robjtede/actix-web-lab/tree/main/actix-hash +[actix-cors]: ./actix-cors +[actix-identity]: ./actix-identity +[actix-limitation]: ./actix-limitation +[actix-protobuf]: ./actix-protobuf +[actix-redis]: ./actix-redis +[actix-session]: ./actix-session +[actix-web-httpauth]: ./actix-web-httpauth +[actix-web-lab]: https://crates.io/crates/actix-web-lab +[actix-multipart-extract]: https://crates.io/crates/actix-multipart-extract +[actix-form-data]: https://crates.io/crates/actix-form-data +[actix-casbin]: https://crates.io/crates/actix-casbin +[actix-ip-filter]: https://crates.io/crates/actix-ip-filter +[actix-web-static-files]: https://crates.io/crates/actix-web-static-files +[actix-web-grants]: https://crates.io/crates/actix-web-grants +[actix-web-flash-messages]: https://crates.io/crates/actix-web-flash-messages +[actix-governor]: https://crates.io/crates/actix-governor +[aliri_actix]: https://crates.io/crates/aliri_actix +[awmp]: https://crates.io/crates/awmp +[tracing-actix-web]: https://crates.io/crates/tracing-actix-web +[actix-ws]: https://crates.io/crates/actix-ws +[actix-hash]: https://crates.io/crates/actix-hash diff --git a/actix-cors/CHANGES.md b/actix-cors/CHANGES.md index 21a489d30..108c65de1 100644 --- a/actix-cors/CHANGES.md +++ b/actix-cors/CHANGES.md @@ -1,6 +1,7 @@ # Changes -## Unreleased - 2021-xx-xx +## Unreleased - 2022-xx-xx +- Minimum supported Rust version (MSRV) is now 1.57 due to transitive `time` dependency. ## 0.6.1 - 2022-03-07 diff --git a/actix-cors/README.md b/actix-cors/README.md index 5d76387ad..dc6f4d112 100644 --- a/actix-cors/README.md +++ b/actix-cors/README.md @@ -11,4 +11,4 @@ - [API Documentation](https://docs.rs/actix-cors) - [Example Project](https://github.com/actix/examples/tree/master/cors) -- Minimum Supported Rust Version (MSRV): 1.54 +- Minimum Supported Rust Version (MSRV): 1.57 diff --git a/actix-cors/src/all_or_some.rs b/actix-cors/src/all_or_some.rs index d8cec53a1..999f7e9b4 100644 --- a/actix-cors/src/all_or_some.rs +++ b/actix-cors/src/all_or_some.rs @@ -1,5 +1,5 @@ /// An enum signifying that some of type `T` is allowed, or `All` (anything is allowed). -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum AllOrSome { /// Everything is allowed. Usually equivalent to the `*` value. All, diff --git a/actix-identity/CHANGES.md b/actix-identity/CHANGES.md index c8dc4d731..1487b3129 100644 --- a/actix-identity/CHANGES.md +++ b/actix-identity/CHANGES.md @@ -1,6 +1,54 @@ # Changes -## Unreleased - 2021-xx-xx +## Unreleased - 2022-xx-xx + + +## 0.5.2 - 2022-07-19 +- Fix visit deadline. [#263] + +[#263]: https://github.com/actix/actix-extras/pull/263 + + +## 0.5.1 - 2022-07-11 +- Remove unnecessary dependencies. [#259] + +[#259]: https://github.com/actix/actix-extras/pull/259 + + +## 0.5.0 - 2022-07-11 +`actix-identity` v0.5 is a complete rewrite. The goal is to streamline user experience and reduce maintenance overhead. + +`actix-identity` is now designed as an additional layer on top of `actix-session` v0.7, focused on identity management. The identity information is stored in the session state, which is managed by `actix-session` and can be stored using any of the supported `SessionStore` implementations. This reduces the surface area in `actix-identity` (e.g., it is no longer concerned with cookies!) and provides a smooth upgrade path for users: if you need to work with sessions, you no longer need to choose between `actix-session` and `actix-identity`; they work together now! + +`actix-identity` v0.5 has feature-parity with `actix-identity` v0.4; if you bump into any blocker when upgrading, please open an issue. + +Changes: + +- Minimum supported Rust version (MSRV) is now 1.57 due to transitive `time` dependency. +- `IdentityService`, `IdentityPolicy` and `CookieIdentityPolicy` have been replaced by `IdentityMiddleware`. [#246] +- Rename `RequestIdentity` trait to `IdentityExt`. [#246] +- Trying to extract an `Identity` for an unauthenticated user will return a `401 Unauthorized` response to the client. Extract an `Option` or a `Result` if you need to handle cases where requests may or may not be authenticated. [#246] + + Example: + + ```rust + use actix_web::{http::header::LOCATION, get, HttpResponse, Responder}; + use actix_identity::Identity; + + #[get("/")] + async fn index(user: Option) -> impl Responder { + if let Some(user) = user { + HttpResponse::Ok().finish() + } else { + // Redirect to login page if unauthenticated + HttpResponse::TemporaryRedirect() + .insert_header((LOCATION, "/login")) + .finish() + } + } + ``` + +[#246]: https://github.com/actix/actix-extras/pull/246 ## 0.4.0 - 2022-03-01 diff --git a/actix-identity/Cargo.toml b/actix-identity/Cargo.toml index 7011da01f..fd82e024e 100644 --- a/actix-identity/Cargo.toml +++ b/actix-identity/Cargo.toml @@ -1,7 +1,10 @@ [package] name = "actix-identity" -version = "0.4.0" -authors = ["Nikolay Kim "] +version = "0.5.2" +authors = [ + "Nikolay Kim ", + "Luca Palmieri ", +] description = "Identity service for Actix Web" keywords = ["actix", "auth", "identity", "web", "security"] homepage = "https://actix.rs" @@ -15,14 +18,20 @@ path = "src/lib.rs" [dependencies] actix-service = "2" +actix-session = "0.7" actix-utils = "3" actix-web = { version = "4", default-features = false, features = ["cookies", "secure-cookies"] } -futures-util = { version = "0.3.7", default-features = false } +anyhow = "1" +futures-core = "0.3.7" serde = { version = "1", features = ["derive"] } -serde_json = "1" -time = "0.3" +tracing = { version = "0.1.30", default-features = false, features = ["log"] } [dev-dependencies] -actix-http = "3.0.0-rc.1" +actix-http = "3" actix-web = { version = "4", default_features = false, features = ["macros", "cookies", "secure-cookies"] } +actix-session = { version = "0.7", features = ["redis-rs-session", "cookie-session"] } + +env_logger = "0.9" +reqwest = { version = "0.11", default_features = false, features = ["cookies", "json"] } +uuid = { version = "1", features = ["v4"] } diff --git a/actix-identity/README.md b/actix-identity/README.md index daa48bc9b..53386d936 100644 --- a/actix-identity/README.md +++ b/actix-identity/README.md @@ -3,11 +3,11 @@ > Identity service for actix-web framework. [![crates.io](https://img.shields.io/crates/v/actix-identity?label=latest)](https://crates.io/crates/actix-identity) -[![Documentation](https://docs.rs/actix-identity/badge.svg?version=0.4.0)](https://docs.rs/actix-identity/0.4.0) +[![Documentation](https://docs.rs/actix-identity/badge.svg?version=0.5.2)](https://docs.rs/actix-identity/0.5.2) ![Apache 2.0 or MIT licensed](https://img.shields.io/crates/l/actix-identity) -[![Dependency Status](https://deps.rs/crate/actix-identity/0.4.0/status.svg)](https://deps.rs/crate/actix-identity/0.4.0) +[![Dependency Status](https://deps.rs/crate/actix-identity/0.5.2/status.svg)](https://deps.rs/crate/actix-identity/0.5.2) ## Documentation & community resources * [API Documentation](https://docs.rs/actix-identity) -* Minimum Supported Rust Version (MSRV): 1.54 +* Minimum Supported Rust Version (MSRV): 1.57 diff --git a/actix-identity/examples/identity.rs b/actix-identity/examples/identity.rs new file mode 100644 index 000000000..e80b69396 --- /dev/null +++ b/actix-identity/examples/identity.rs @@ -0,0 +1,84 @@ +//! A rudimentary example of how to set up and use `actix-identity`. +//! +//! ```bash +//! # using HTTPie (https://httpie.io/cli) +//! +//! # outputs "Welcome Anonymous!" message +//! http -v --session=identity GET localhost:8080/ +//! +//! # log in using fake details, ensuring that --session is used to persist cookies +//! http -v --session=identity POST localhost:8080/login user_id=foo +//! +//! # outputs "Welcome User1" message +//! http -v --session=identity GET localhost:8080/ +//! ``` + +use std::io; + +use actix_identity::{Identity, IdentityMiddleware}; +use actix_session::{storage::CookieSessionStore, SessionMiddleware}; +use actix_web::{ + cookie::Key, get, middleware::Logger, post, App, HttpMessage, HttpRequest, HttpResponse, + HttpServer, Responder, +}; + +#[actix_web::main] +async fn main() -> io::Result<()> { + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + let secret_key = Key::generate(); + + HttpServer::new(move || { + let session_mw = + SessionMiddleware::builder(CookieSessionStore::default(), secret_key.clone()) + // disable secure cookie for local testing + .cookie_secure(false) + .build(); + + App::new() + // Install the identity framework first. + .wrap(IdentityMiddleware::default()) + // The identity system is built on top of sessions. You must install the session + // middleware to leverage `actix-identity`. The session middleware must be mounted + // AFTER the identity middleware: `actix-web` invokes middleware in the OPPOSITE + // order of registration when it receives an incoming request. + .wrap(session_mw) + .wrap(Logger::default()) + .service(index) + .service(login) + .service(logout) + }) + .bind(("127.0.0.1", 8080)) + .unwrap() + .workers(2) + .run() + .await +} + +#[get("/")] +async fn index(user: Option) -> impl Responder { + if let Some(user) = user { + format!("Welcome! {}", user.id().unwrap()) + } else { + "Welcome Anonymous!".to_owned() + } +} + +#[post("/login")] +async fn login(request: HttpRequest) -> impl Responder { + // Some kind of authentication should happen here - + // e.g. password-based, biometric, etc. + // [...] + + // Attached a verified user identity to the active + // session. + Identity::login(&request.extensions(), "User1".into()).unwrap(); + + HttpResponse::Ok() +} + +#[post("/logout")] +async fn logout(user: Identity) -> impl Responder { + user.logout(); + HttpResponse::NoContent() +} diff --git a/actix-identity/src/config.rs b/actix-identity/src/config.rs new file mode 100644 index 000000000..a10514e9c --- /dev/null +++ b/actix-identity/src/config.rs @@ -0,0 +1,101 @@ +//! Configuration options to tune the behaviour of [`IdentityMiddleware`]. + +use std::time::Duration; + +use crate::IdentityMiddleware; + +#[derive(Debug, Clone)] +pub(crate) struct Configuration { + pub(crate) on_logout: LogoutBehaviour, + pub(crate) login_deadline: Option, + pub(crate) visit_deadline: Option, +} + +impl Default for Configuration { + fn default() -> Self { + Self { + on_logout: LogoutBehaviour::PurgeSession, + login_deadline: None, + visit_deadline: None, + } + } +} + +/// `LogoutBehaviour` controls what actions are going to be performed when [`Identity::logout`] is +/// invoked. +/// +/// [`Identity::logout`]: crate::Identity::logout +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum LogoutBehaviour { + /// When [`Identity::logout`](crate::Identity::logout) is called, purge the current session. + /// + /// This behaviour might be desirable when you have stored additional information in the + /// session state that are tied to the user's identity and should not be retained after logout. + PurgeSession, + + /// When [`Identity::logout`](crate::Identity::logout) is called, remove the identity + /// information from the current session state. The session itself is not destroyed. + /// + /// This behaviour might be desirable when you have stored information in the session state that + /// is not tied to the user's identity and should be retained after logout. + DeleteIdentityKeys, +} + +/// A fluent builder to construct an [`IdentityMiddleware`] instance with custom configuration +/// parameters. +/// +/// Use [`IdentityMiddleware::builder`] to get started! +#[derive(Debug, Clone)] +pub struct IdentityMiddlewareBuilder { + configuration: Configuration, +} + +impl IdentityMiddlewareBuilder { + pub(crate) fn new() -> Self { + Self { + configuration: Configuration::default(), + } + } + + /// Determines how [`Identity::logout`](crate::Identity::logout) affects the current session. + /// + /// By default, the current session is purged ([`LogoutBehaviour::PurgeSession`]). + pub fn logout_behaviour(mut self, logout_behaviour: LogoutBehaviour) -> Self { + self.configuration.on_logout = logout_behaviour; + self + } + + /// Automatically logs out users after a certain amount of time has passed since they logged in, + /// regardless of their activity pattern. + /// + /// If set to: + /// - `None`: login deadline is disabled. + /// - `Some(duration)`: login deadline is enabled and users will be logged out after `duration` + /// has passed since their login. + /// + /// By default, login deadline is disabled. + pub fn login_deadline(mut self, deadline: Option) -> Self { + self.configuration.login_deadline = deadline; + self + } + + /// Automatically logs out users after a certain amount of time has passed since their last + /// visit. + /// + /// If set to: + /// - `None`: visit deadline is disabled. + /// - `Some(duration)`: visit deadline is enabled and users will be logged out after `duration` + /// has passed since their last visit. + /// + /// By default, visit deadline is disabled. + pub fn visit_deadline(mut self, deadline: Option) -> Self { + self.configuration.visit_deadline = deadline; + self + } + + /// Finalises the builder and returns an [`IdentityMiddleware`] instance. + pub fn build(self) -> IdentityMiddleware { + IdentityMiddleware::new(self.configuration) + } +} diff --git a/actix-identity/src/cookie.rs b/actix-identity/src/cookie.rs deleted file mode 100644 index 0e9733688..000000000 --- a/actix-identity/src/cookie.rs +++ /dev/null @@ -1,828 +0,0 @@ -use std::{rc::Rc, time::SystemTime}; - -use actix_utils::future::{ready, Ready}; -use actix_web::{ - cookie::{Cookie, CookieJar, Key, SameSite}, - dev::{ServiceRequest, ServiceResponse}, - error::{Error, Result}, - http::header::{self, HeaderValue}, - HttpMessage, -}; -use serde::{Deserialize, Serialize}; -use time::Duration; - -use crate::IdentityPolicy; - -struct CookieIdentityInner { - key: Key, - key_v2: Key, - name: String, - path: String, - domain: Option, - secure: bool, - max_age: Option, - http_only: Option, - same_site: Option, - visit_deadline: Option, - login_deadline: Option, -} - -#[derive(Debug, Deserialize, Serialize)] -struct CookieValue { - identity: String, - - #[serde(skip_serializing_if = "Option::is_none")] - login_timestamp: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - visit_timestamp: Option, -} - -#[derive(Debug)] -struct CookieIdentityExtension { - login_timestamp: Option, -} - -impl CookieIdentityInner { - fn new(key: &[u8]) -> CookieIdentityInner { - let key_v2: Vec = [key, &[1, 0, 0, 0]].concat(); - - CookieIdentityInner { - key: Key::derive_from(key), - key_v2: Key::derive_from(&key_v2), - name: "actix-identity".to_owned(), - path: "/".to_owned(), - domain: None, - secure: true, - max_age: None, - http_only: None, - same_site: None, - visit_deadline: None, - login_deadline: None, - } - } - - fn set_cookie( - &self, - resp: &mut ServiceResponse, - value: Option, - ) -> Result<()> { - let add_cookie = value.is_some(); - let val = value - .map(|val| { - if !self.legacy_supported() { - serde_json::to_string(&val) - } else { - Ok(val.identity) - } - }) - .transpose()?; - - let mut cookie = Cookie::new(self.name.clone(), val.unwrap_or_default()); - cookie.set_path(self.path.clone()); - cookie.set_secure(self.secure); - cookie.set_http_only(true); - - if let Some(ref domain) = self.domain { - cookie.set_domain(domain.clone()); - } - - if let Some(max_age) = self.max_age { - cookie.set_max_age(max_age); - } - - if let Some(http_only) = self.http_only { - cookie.set_http_only(http_only); - } - - if let Some(same_site) = self.same_site { - cookie.set_same_site(same_site); - } - - let mut jar = CookieJar::new(); - - let key = if self.legacy_supported() { - &self.key - } else { - &self.key_v2 - }; - - if add_cookie { - jar.private_mut(key).add(cookie); - } else { - jar.add_original(cookie.clone()); - jar.private_mut(key).remove(cookie); - } - - for cookie in jar.delta() { - let val = HeaderValue::from_str(&cookie.to_string())?; - resp.headers_mut().append(header::SET_COOKIE, val); - } - - Ok(()) - } - - fn load(&self, req: &ServiceRequest) -> Option { - let cookie = req.cookie(&self.name)?; - let mut jar = CookieJar::new(); - jar.add_original(cookie.clone()); - - let res = if self.legacy_supported() { - jar.private_mut(&self.key) - .get(&self.name) - .map(|n| CookieValue { - identity: n.value().to_string(), - login_timestamp: None, - visit_timestamp: None, - }) - } else { - None - }; - - res.or_else(|| { - jar.private_mut(&self.key_v2) - .get(&self.name) - .and_then(|c| self.parse(c)) - }) - } - - fn parse(&self, cookie: Cookie<'_>) -> Option { - let value: CookieValue = serde_json::from_str(cookie.value()).ok()?; - let now = SystemTime::now(); - - if let Some(visit_deadline) = self.visit_deadline { - let inactivity = now.duration_since(value.visit_timestamp?).ok()?; - - if inactivity > visit_deadline { - return None; - } - } - - if let Some(login_deadline) = self.login_deadline { - let logged_in_dur = now.duration_since(value.login_timestamp?).ok()?; - - if logged_in_dur > login_deadline { - return None; - } - } - - Some(value) - } - - fn legacy_supported(&self) -> bool { - self.visit_deadline.is_none() && self.login_deadline.is_none() - } - - fn always_update_cookie(&self) -> bool { - self.visit_deadline.is_some() - } - - fn requires_oob_data(&self) -> bool { - self.login_deadline.is_some() - } -} - -/// Use cookies for request identity storage. -/// -/// [See this page on MDN](mdn-cookies) for details on cookie attributes. -/// -/// # Examples -/// ``` -/// use actix_web::App; -/// use actix_identity::{CookieIdentityPolicy, IdentityService}; -/// -/// // create cookie identity backend -/// let policy = CookieIdentityPolicy::new(&[0; 32]) -/// .domain("www.rust-lang.org") -/// .name("actix_auth") -/// .path("/") -/// .secure(true); -/// -/// let app = App::new() -/// // wrap policy into identity middleware -/// .wrap(IdentityService::new(policy)); -/// ``` -/// -/// [mdn-cookies]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies -pub struct CookieIdentityPolicy(Rc); - -impl CookieIdentityPolicy { - /// Create new `CookieIdentityPolicy` instance. - /// - /// Key argument is the private key for issued cookies. If this value is changed, all issued - /// cookie identities are invalidated. - /// - /// # Panics - /// Panics if `key` is less than 32 bytes in length.. - pub fn new(key: &[u8]) -> CookieIdentityPolicy { - CookieIdentityPolicy(Rc::new(CookieIdentityInner::new(key))) - } - - /// Sets the name of issued cookies. - pub fn name(mut self, value: impl Into) -> CookieIdentityPolicy { - self.inner_mut().name = value.into(); - self - } - - /// Sets the `Path` attribute of issued cookies. - pub fn path(mut self, value: impl Into) -> CookieIdentityPolicy { - self.inner_mut().path = value.into(); - self - } - - /// Sets the `Domain` attribute of issued cookies. - pub fn domain(mut self, value: impl Into) -> CookieIdentityPolicy { - self.inner_mut().domain = Some(value.into()); - self - } - - /// Sets the `Secure` attribute of issued cookies. - pub fn secure(mut self, value: bool) -> CookieIdentityPolicy { - self.inner_mut().secure = value; - self - } - - /// Sets the `Max-Age` attribute of issued cookies. - pub fn max_age(mut self, value: Duration) -> CookieIdentityPolicy { - self.inner_mut().max_age = Some(value); - self - } - - /// Sets the `Max-Age` attribute of issued cookies with given number of seconds. - pub fn max_age_secs(self, seconds: i64) -> CookieIdentityPolicy { - self.max_age(Duration::seconds(seconds)) - } - - /// Sets the `HttpOnly` attribute of issued cookies. - /// - /// By default, the `HttpOnly` attribute is omitted from issued cookies. - pub fn http_only(mut self, http_only: bool) -> Self { - self.inner_mut().http_only = Some(http_only); - self - } - - /// Sets the `SameSite` attribute of issued cookies. - /// - /// By default, the `SameSite` attribute is omitted from issued cookies. - pub fn same_site(mut self, same_site: SameSite) -> Self { - self.inner_mut().same_site = Some(same_site); - self - } - - /// Accepts only users who have visited within given deadline. - /// - /// In other words, invalidate a login after some amount of inactivity. Using this feature - /// causes updated cookies to be issued on each response in order to record the user's last - /// visitation timestamp. - /// - /// By default, visit deadline is disabled. - pub fn visit_deadline(mut self, deadline: Duration) -> CookieIdentityPolicy { - self.inner_mut().visit_deadline = Some(deadline); - self - } - - /// Accepts only users who authenticated within the given deadline. - /// - /// In other words, invalidate a login after some amount of time, regardless of activity. - /// While [`Max-Age`](CookieIdentityPolicy::max_age) is useful in constraining the cookie - /// lifetime, it could be extended manually; using this feature encodes the deadline directly - /// into the issued cookies, making it immutable to users. - /// - /// By default, login deadline is disabled. - pub fn login_deadline(mut self, deadline: Duration) -> CookieIdentityPolicy { - self.inner_mut().login_deadline = Some(deadline); - self - } - - fn inner_mut(&mut self) -> &mut CookieIdentityInner { - Rc::get_mut(&mut self.0).unwrap() - } -} - -impl IdentityPolicy for CookieIdentityPolicy { - type Future = Ready, Error>>; - type ResponseFuture = Ready>; - - fn from_request(&self, req: &mut ServiceRequest) -> Self::Future { - ready(Ok(self.0.load(req).map(|value| { - let CookieValue { - identity, - login_timestamp, - .. - } = value; - - if self.0.requires_oob_data() { - req.extensions_mut() - .insert(CookieIdentityExtension { login_timestamp }); - } - - identity - }))) - } - - fn to_response( - &self, - id: Option, - changed: bool, - res: &mut ServiceResponse, - ) -> Self::ResponseFuture { - let _ = if changed { - let login_timestamp = SystemTime::now(); - - self.0.set_cookie( - res, - id.map(|identity| CookieValue { - identity, - login_timestamp: self.0.login_deadline.map(|_| login_timestamp), - visit_timestamp: self.0.visit_deadline.map(|_| login_timestamp), - }), - ) - } else if self.0.always_update_cookie() && id.is_some() { - let visit_timestamp = SystemTime::now(); - - let login_timestamp = if self.0.requires_oob_data() { - let CookieIdentityExtension { login_timestamp } = - res.request().extensions_mut().remove().unwrap(); - - login_timestamp - } else { - None - }; - - self.0.set_cookie( - res, - Some(CookieValue { - identity: id.unwrap(), - login_timestamp, - visit_timestamp: self.0.visit_deadline.map(|_| visit_timestamp), - }), - ) - } else { - Ok(()) - }; - - ready(Ok(())) - } -} - -#[cfg(test)] -mod tests { - use std::{borrow::Borrow, time::SystemTime}; - - use actix_web::{ - body::{BoxBody, EitherBody}, - cookie::{Cookie, CookieJar, Key, SameSite}, - dev::ServiceResponse, - http::{header, StatusCode}, - test::{self, TestRequest}, - web, App, HttpResponse, - }; - use time::Duration; - - use super::*; - use crate::{tests::*, Identity, IdentityService}; - - fn login_cookie( - identity: &'static str, - login_timestamp: Option, - visit_timestamp: Option, - ) -> Cookie<'static> { - let mut jar = CookieJar::new(); - let key: Vec = COOKIE_KEY_MASTER - .iter() - .chain([1, 0, 0, 0].iter()) - .copied() - .collect(); - - jar.private_mut(&Key::derive_from(&key)).add(Cookie::new( - COOKIE_NAME, - serde_json::to_string(&CookieValue { - identity: identity.to_string(), - login_timestamp, - visit_timestamp, - }) - .unwrap(), - )); - - jar.get(COOKIE_NAME).unwrap().clone() - } - - fn assert_login_cookie( - response: &mut ServiceResponse>, - identity: &str, - login_timestamp: LoginTimestampCheck, - visit_timestamp: VisitTimeStampCheck, - ) { - let mut cookies = CookieJar::new(); - - for cookie in response.headers().get_all(header::SET_COOKIE) { - cookies.add(Cookie::parse(cookie.to_str().unwrap().to_string()).unwrap()); - } - - let key: Vec = COOKIE_KEY_MASTER - .iter() - .chain([1, 0, 0, 0].iter()) - .copied() - .collect(); - - let cookie = cookies - .private(&Key::derive_from(&key)) - .get(COOKIE_NAME) - .unwrap(); - - let cv: CookieValue = serde_json::from_str(cookie.value()).unwrap(); - assert_eq!(cv.identity, identity); - - let now = SystemTime::now(); - let t30sec_ago = now - Duration::seconds(30); - - match login_timestamp { - LoginTimestampCheck::NoTimestamp => assert_eq!(cv.login_timestamp, None), - LoginTimestampCheck::NewTimestamp => assert!( - t30sec_ago <= cv.login_timestamp.unwrap() && cv.login_timestamp.unwrap() <= now - ), - LoginTimestampCheck::OldTimestamp(old_timestamp) => { - assert_eq!(cv.login_timestamp, Some(old_timestamp)) - } - } - - match visit_timestamp { - VisitTimeStampCheck::NoTimestamp => assert_eq!(cv.visit_timestamp, None), - VisitTimeStampCheck::NewTimestamp => assert!( - t30sec_ago <= cv.visit_timestamp.unwrap() && cv.visit_timestamp.unwrap() <= now - ), - } - } - - #[actix_web::test] - async fn test_identity_flow() { - let srv = test::init_service( - App::new() - .wrap(IdentityService::new( - CookieIdentityPolicy::new(&COOKIE_KEY_MASTER) - .domain("www.rust-lang.org") - .name(COOKIE_NAME) - .path("/") - .secure(true), - )) - .service(web::resource("/index").to(|id: Identity| { - if id.identity().is_some() { - HttpResponse::Created() - } else { - HttpResponse::Ok() - } - })) - .service(web::resource("/login").to(|id: Identity| { - id.remember(COOKIE_LOGIN.to_string()); - HttpResponse::Ok() - })) - .service(web::resource("/logout").to(|id: Identity| { - if id.identity().is_some() { - id.forget(); - HttpResponse::Ok() - } else { - HttpResponse::BadRequest() - } - })), - ) - .await; - let resp = test::call_service(&srv, TestRequest::with_uri("/index").to_request()).await; - assert_eq!(resp.status(), StatusCode::OK); - - let resp = test::call_service(&srv, TestRequest::with_uri("/login").to_request()).await; - assert_eq!(resp.status(), StatusCode::OK); - let c = resp.response().cookies().next().unwrap().to_owned(); - - let resp = test::call_service( - &srv, - TestRequest::with_uri("/index") - .cookie(c.clone()) - .to_request(), - ) - .await; - assert_eq!(resp.status(), StatusCode::CREATED); - - let resp = test::call_service( - &srv, - TestRequest::with_uri("/logout") - .cookie(c.clone()) - .to_request(), - ) - .await; - assert_eq!(resp.status(), StatusCode::OK); - assert!(resp.headers().contains_key(header::SET_COOKIE)) - } - - #[actix_web::test] - async fn test_identity_max_age_time() { - let duration = Duration::days(1); - - let srv = test::init_service( - App::new() - .wrap(IdentityService::new( - CookieIdentityPolicy::new(&COOKIE_KEY_MASTER) - .domain("www.rust-lang.org") - .name(COOKIE_NAME) - .path("/") - .max_age(duration) - .secure(true), - )) - .service(web::resource("/login").to(|id: Identity| { - id.remember("test".to_string()); - HttpResponse::Ok() - })), - ) - .await; - - let resp = test::call_service(&srv, TestRequest::with_uri("/login").to_request()).await; - assert_eq!(resp.status(), StatusCode::OK); - assert!(resp.headers().contains_key(header::SET_COOKIE)); - let c = resp.response().cookies().next().unwrap().to_owned(); - assert_eq!(duration, c.max_age().unwrap()); - } - - #[actix_web::test] - async fn test_http_only_same_site() { - let srv = test::init_service( - App::new() - .wrap(IdentityService::new( - CookieIdentityPolicy::new(&COOKIE_KEY_MASTER) - .domain("www.rust-lang.org") - .name(COOKIE_NAME) - .path("/") - .http_only(true) - .same_site(SameSite::None), - )) - .service(web::resource("/login").to(|id: Identity| { - id.remember("test".to_string()); - HttpResponse::Ok() - })), - ) - .await; - - let resp = test::call_service(&srv, TestRequest::with_uri("/login").to_request()).await; - - assert_eq!(resp.status(), StatusCode::OK); - assert!(resp.headers().contains_key(header::SET_COOKIE)); - - let c = resp.response().cookies().next().unwrap().to_owned(); - assert!(c.http_only().unwrap()); - assert_eq!(SameSite::None, c.same_site().unwrap()); - } - - fn legacy_login_cookie(identity: &'static str) -> Cookie<'static> { - let mut jar = CookieJar::new(); - jar.private_mut(&Key::derive_from(&COOKIE_KEY_MASTER)) - .add(Cookie::new(COOKIE_NAME, identity)); - jar.get(COOKIE_NAME).unwrap().clone() - } - - async fn assert_logged_in( - response: ServiceResponse>, - identity: Option<&str>, - ) { - let bytes = test::read_body(response).await; - let resp: Option = serde_json::from_slice(&bytes[..]).unwrap(); - assert_eq!(resp.as_ref().map(|s| s.borrow()), identity); - } - - fn assert_legacy_login_cookie( - response: &mut ServiceResponse>, - identity: &str, - ) { - let mut cookies = CookieJar::new(); - for cookie in response.headers().get_all(header::SET_COOKIE) { - cookies.add(Cookie::parse(cookie.to_str().unwrap().to_string()).unwrap()); - } - let cookie = cookies - .private_mut(&Key::derive_from(&COOKIE_KEY_MASTER)) - .get(COOKIE_NAME) - .unwrap(); - assert_eq!(cookie.value(), identity); - } - - fn assert_no_login_cookie(response: &mut ServiceResponse>) { - let mut cookies = CookieJar::new(); - for cookie in response.headers().get_all(header::SET_COOKIE) { - cookies.add(Cookie::parse(cookie.to_str().unwrap().to_string()).unwrap()); - } - assert!(cookies.get(COOKIE_NAME).is_none()); - } - - #[actix_web::test] - async fn test_identity_max_age() { - let seconds = 60; - let srv = test::init_service( - App::new() - .wrap(IdentityService::new( - CookieIdentityPolicy::new(&COOKIE_KEY_MASTER) - .domain("www.rust-lang.org") - .name(COOKIE_NAME) - .path("/") - .max_age_secs(seconds) - .secure(true), - )) - .service(web::resource("/login").to(|id: Identity| { - id.remember("test".to_string()); - HttpResponse::Ok() - })), - ) - .await; - let resp = test::call_service(&srv, TestRequest::with_uri("/login").to_request()).await; - assert_eq!(resp.status(), StatusCode::OK); - assert!(resp.headers().contains_key(header::SET_COOKIE)); - let c = resp.response().cookies().next().unwrap().to_owned(); - assert_eq!(Duration::seconds(seconds as i64), c.max_age().unwrap()); - } - - #[actix_web::test] - async fn test_identity_legacy_cookie_is_set() { - let srv = create_identity_server(|c| c).await; - let mut resp = test::call_service(&srv, TestRequest::with_uri("/").to_request()).await; - assert_legacy_login_cookie(&mut resp, COOKIE_LOGIN); - assert_logged_in(resp, None).await; - } - - #[actix_web::test] - async fn test_identity_legacy_cookie_works() { - let srv = create_identity_server(|c| c).await; - let cookie = legacy_login_cookie(COOKIE_LOGIN); - let mut resp = test::call_service( - &srv, - TestRequest::with_uri("/") - .cookie(cookie.clone()) - .to_request(), - ) - .await; - assert_no_login_cookie(&mut resp); - assert_logged_in(resp, Some(COOKIE_LOGIN)).await; - } - - #[actix_web::test] - async fn test_identity_legacy_cookie_rejected_if_visit_timestamp_needed() { - let srv = create_identity_server(|c| c.visit_deadline(Duration::days(90))).await; - let cookie = legacy_login_cookie(COOKIE_LOGIN); - let mut resp = test::call_service( - &srv, - TestRequest::with_uri("/") - .cookie(cookie.clone()) - .to_request(), - ) - .await; - assert_login_cookie( - &mut resp, - COOKIE_LOGIN, - LoginTimestampCheck::NoTimestamp, - VisitTimeStampCheck::NewTimestamp, - ); - assert_logged_in(resp, None).await; - } - - #[actix_web::test] - async fn test_identity_legacy_cookie_rejected_if_login_timestamp_needed() { - let srv = create_identity_server(|c| c.login_deadline(Duration::days(90))).await; - let cookie = legacy_login_cookie(COOKIE_LOGIN); - let mut resp = test::call_service( - &srv, - TestRequest::with_uri("/") - .cookie(cookie.clone()) - .to_request(), - ) - .await; - assert_login_cookie( - &mut resp, - COOKIE_LOGIN, - LoginTimestampCheck::NewTimestamp, - VisitTimeStampCheck::NoTimestamp, - ); - assert_logged_in(resp, None).await; - } - - #[actix_web::test] - async fn test_identity_cookie_rejected_if_login_timestamp_needed() { - let srv = create_identity_server(|c| c.login_deadline(Duration::days(90))).await; - let cookie = login_cookie(COOKIE_LOGIN, None, Some(SystemTime::now())); - let mut resp = test::call_service( - &srv, - TestRequest::with_uri("/") - .cookie(cookie.clone()) - .to_request(), - ) - .await; - assert_login_cookie( - &mut resp, - COOKIE_LOGIN, - LoginTimestampCheck::NewTimestamp, - VisitTimeStampCheck::NoTimestamp, - ); - assert_logged_in(resp, None).await; - } - - #[actix_web::test] - async fn test_identity_cookie_rejected_if_visit_timestamp_needed() { - let srv = create_identity_server(|c| c.visit_deadline(Duration::days(90))).await; - let cookie = login_cookie(COOKIE_LOGIN, Some(SystemTime::now()), None); - let mut resp = test::call_service( - &srv, - TestRequest::with_uri("/") - .cookie(cookie.clone()) - .to_request(), - ) - .await; - assert_login_cookie( - &mut resp, - COOKIE_LOGIN, - LoginTimestampCheck::NoTimestamp, - VisitTimeStampCheck::NewTimestamp, - ); - assert_logged_in(resp, None).await; - } - - #[actix_web::test] - async fn test_identity_cookie_rejected_if_login_timestamp_too_old() { - let srv = create_identity_server(|c| c.login_deadline(Duration::days(90))).await; - let cookie = login_cookie( - COOKIE_LOGIN, - Some(SystemTime::now() - Duration::days(180)), - None, - ); - let mut resp = test::call_service( - &srv, - TestRequest::with_uri("/") - .cookie(cookie.clone()) - .to_request(), - ) - .await; - assert_login_cookie( - &mut resp, - COOKIE_LOGIN, - LoginTimestampCheck::NewTimestamp, - VisitTimeStampCheck::NoTimestamp, - ); - assert_logged_in(resp, None).await; - } - - #[actix_web::test] - async fn test_identity_cookie_rejected_if_visit_timestamp_too_old() { - let srv = create_identity_server(|c| c.visit_deadline(Duration::days(90))).await; - let cookie = login_cookie( - COOKIE_LOGIN, - None, - Some(SystemTime::now() - Duration::days(180)), - ); - let mut resp = test::call_service( - &srv, - TestRequest::with_uri("/") - .cookie(cookie.clone()) - .to_request(), - ) - .await; - assert_login_cookie( - &mut resp, - COOKIE_LOGIN, - LoginTimestampCheck::NoTimestamp, - VisitTimeStampCheck::NewTimestamp, - ); - assert_logged_in(resp, None).await; - } - - #[actix_web::test] - async fn test_identity_cookie_not_updated_on_login_deadline() { - let srv = create_identity_server(|c| c.login_deadline(Duration::days(90))).await; - let cookie = login_cookie(COOKIE_LOGIN, Some(SystemTime::now()), None); - let mut resp = test::call_service( - &srv, - TestRequest::with_uri("/") - .cookie(cookie.clone()) - .to_request(), - ) - .await; - assert_no_login_cookie(&mut resp); - assert_logged_in(resp, Some(COOKIE_LOGIN)).await; - } - - #[actix_web::test] - async fn test_identity_cookie_updated_on_visit_deadline() { - let srv = create_identity_server(|c| { - c.visit_deadline(Duration::days(90)) - .login_deadline(Duration::days(90)) - }) - .await; - let timestamp = SystemTime::now() - Duration::days(1); - let cookie = login_cookie(COOKIE_LOGIN, Some(timestamp), Some(timestamp)); - let mut resp = test::call_service( - &srv, - TestRequest::with_uri("/") - .cookie(cookie.clone()) - .to_request(), - ) - .await; - assert_login_cookie( - &mut resp, - COOKIE_LOGIN, - LoginTimestampCheck::OldTimestamp(timestamp), - VisitTimeStampCheck::NewTimestamp, - ); - assert_logged_in(resp, Some(COOKIE_LOGIN)).await; - } -} diff --git a/actix-identity/src/identity.rs b/actix-identity/src/identity.rs index 366e8303d..0b6342736 100644 --- a/actix-identity/src/identity.rs +++ b/actix-identity/src/identity.rs @@ -1,89 +1,247 @@ +use actix_session::Session; use actix_utils::future::{ready, Ready}; use actix_web::{ + cookie::time::OffsetDateTime, dev::{Extensions, Payload}, - Error, FromRequest, HttpMessage as _, HttpRequest, + http::StatusCode, + Error, FromRequest, HttpMessage, HttpRequest, HttpResponse, }; +use anyhow::{anyhow, Context}; -pub(crate) struct IdentityItem { - pub(crate) id: Option, - pub(crate) changed: bool, -} +use crate::config::LogoutBehaviour; -/// The extractor type to obtain your identity from a request. +/// A verified user identity. It can be used as a request extractor. /// +/// The lifecycle of a user identity is tied to the lifecycle of the underlying session. If the +/// session is destroyed (e.g. the session expired), the user identity will be forgotten, de-facto +/// forcing a user log out. +/// +/// # Examples /// ``` -/// use actix_web::*; +/// use actix_web::{ +/// get, post, Responder, HttpRequest, HttpMessage, HttpResponse +/// }; /// use actix_identity::Identity; /// /// #[get("/")] -/// async fn index(id: Identity) -> impl Responder { -/// // access request identity -/// if let Some(id) = id.identity() { -/// format!("Welcome! {}", id) +/// async fn index(user: Option) -> impl Responder { +/// if let Some(user) = user { +/// format!("Welcome! {}", user.id().unwrap()) /// } else { /// "Welcome Anonymous!".to_owned() /// } /// } /// /// #[post("/login")] -/// async fn login(id: Identity) -> impl Responder { -/// // remember identity -/// id.remember("User1".to_owned()); -/// +/// async fn login(request: HttpRequest) -> impl Responder { +/// Identity::login(&request.extensions(), "User1".into()); /// HttpResponse::Ok() /// } /// /// #[post("/logout")] -/// async fn logout(id: Identity) -> impl Responder { -/// // remove identity -/// id.forget(); -/// +/// async fn logout(user: Identity) -> impl Responder { +/// user.logout(); /// HttpResponse::Ok() /// } /// ``` -#[derive(Clone)] -pub struct Identity(HttpRequest); - -impl Identity { - /// Return the claimed identity of the user associated request or `None` if no identity can be - /// found associated with the request. - pub fn identity(&self) -> Option { - Identity::get_identity(&self.0.extensions()) - } - - /// Remember identity. - pub fn remember(&self, identity: String) { - if let Some(id) = self.0.extensions_mut().get_mut::() { - id.id = Some(identity); - id.changed = true; - } - } - - /// This method is used to 'forget' the current identity on subsequent requests. - pub fn forget(&self) { - if let Some(id) = self.0.extensions_mut().get_mut::() { - id.id = None; - id.changed = true; - } - } - - pub(crate) fn get_identity(extensions: &Extensions) -> Option { - let id = extensions.get::()?; - id.id.clone() - } -} - -/// Extractor implementation for Identity type. /// +/// # Extractor Behaviour +/// What happens if you try to extract an `Identity` out of a request that does not have a valid +/// identity attached? The API will return a `401 UNAUTHORIZED` to the caller. +/// +/// If you want to customise this behaviour, consider extracting `Option` or +/// `Result` instead of a bare `Identity`: you will then be fully in +/// control of the error path. +/// +/// ## Examples /// ``` -/// # use actix_web::*; +/// use actix_web::{http::header::LOCATION, get, HttpResponse, Responder}; /// use actix_identity::Identity; /// /// #[get("/")] -/// async fn index(id: Identity) -> impl Responder { -/// // access request identity -/// if let Some(id) = id.identity() { -/// format!("Welcome! {}", id) +/// async fn index(user: Option) -> impl Responder { +/// if let Some(user) = user { +/// HttpResponse::Ok().finish() +/// } else { +/// // Redirect to login page if unauthenticated +/// HttpResponse::TemporaryRedirect() +/// .insert_header((LOCATION, "/login")) +/// .finish() +/// } +/// } +/// ``` +pub struct Identity(IdentityInner); + +#[derive(Clone)] +pub(crate) struct IdentityInner { + pub(crate) session: Session, + pub(crate) logout_behaviour: LogoutBehaviour, + pub(crate) is_login_deadline_enabled: bool, + pub(crate) is_visit_deadline_enabled: bool, +} + +impl IdentityInner { + fn extract(ext: &Extensions) -> Self { + ext.get::() + .expect( + "No `IdentityInner` instance was found in the extensions attached to the \ + incoming request. This usually means that `IdentityMiddleware` has not been \ + registered as an application middleware via `App::wrap`. `Identity` cannot be used \ + unless the identity machine is properly mounted: register `IdentityMiddleware` as \ + a middleware for your application to fix this panic. If the problem persists, \ + please file an issue on GitHub.", + ) + .to_owned() + } + + /// Retrieve the user id attached to the current session. + fn get_identity(&self) -> Result { + self.session + .get::(ID_KEY) + .context("Failed to deserialize the user identifier attached to the current session")? + .ok_or_else(|| { + anyhow!("There is no identity information attached to the current session") + }) + } +} + +pub(crate) const ID_KEY: &str = "actix_identity.user_id"; +pub(crate) const LAST_VISIT_UNIX_TIMESTAMP_KEY: &str = "actix_identity.last_visited_at"; +pub(crate) const LOGIN_UNIX_TIMESTAMP_KEY: &str = "actix_identity.logged_in_at"; + +impl Identity { + /// Return the user id associated to the current session. + /// + /// # Examples + /// ``` + /// use actix_web::{get, Responder}; + /// use actix_identity::Identity; + /// + /// #[get("/")] + /// async fn index(user: Option) -> impl Responder { + /// if let Some(user) = user { + /// format!("Welcome! {}", user.id().unwrap()) + /// } else { + /// "Welcome Anonymous!".to_owned() + /// } + /// } + /// ``` + pub fn id(&self) -> Result { + self.0.session.get(ID_KEY)?.ok_or_else(|| { + anyhow!("Bug: the identity information attached to the current session has disappeared") + }) + } + + /// Attach a valid user identity to the current session. + /// + /// This method should be called after you have successfully authenticated the user. After + /// `login` has been called, the user will be able to access all routes that require a valid + /// [`Identity`]. + /// + /// # Examples + /// ``` + /// use actix_web::{post, Responder, HttpRequest, HttpMessage, HttpResponse}; + /// use actix_identity::Identity; + /// + /// #[post("/login")] + /// async fn login(request: HttpRequest) -> impl Responder { + /// Identity::login(&request.extensions(), "User1".into()); + /// HttpResponse::Ok() + /// } + /// ``` + pub fn login(ext: &Extensions, id: String) -> Result { + let inner = IdentityInner::extract(ext); + inner.session.insert(ID_KEY, id)?; + let now = OffsetDateTime::now_utc().unix_timestamp(); + if inner.is_login_deadline_enabled { + inner.session.insert(LOGIN_UNIX_TIMESTAMP_KEY, now)?; + } + if inner.is_visit_deadline_enabled { + inner.session.insert(LAST_VISIT_UNIX_TIMESTAMP_KEY, now)?; + } + inner.session.renew(); + Ok(Self(inner)) + } + + /// Remove the user identity from the current session. + /// + /// After `logout` has been called, the user will no longer be able to access routes that + /// require a valid [`Identity`]. + /// + /// The behaviour on logout is determined by [`IdentityMiddlewareBuilder::logout_behaviour`]. + /// + /// # Examples + /// ``` + /// use actix_web::{post, Responder, HttpResponse}; + /// use actix_identity::Identity; + /// + /// #[post("/logout")] + /// async fn logout(user: Identity) -> impl Responder { + /// user.logout(); + /// HttpResponse::Ok() + /// } + /// ``` + /// + /// [`IdentityMiddlewareBuilder::logout_behaviour`]: crate::config::IdentityMiddlewareBuilder::logout_behaviour + pub fn logout(self) { + match self.0.logout_behaviour { + LogoutBehaviour::PurgeSession => { + self.0.session.purge(); + } + LogoutBehaviour::DeleteIdentityKeys => { + self.0.session.remove(ID_KEY); + if self.0.is_login_deadline_enabled { + self.0.session.remove(LOGIN_UNIX_TIMESTAMP_KEY); + } + if self.0.is_visit_deadline_enabled { + self.0.session.remove(LAST_VISIT_UNIX_TIMESTAMP_KEY); + } + } + } + } + + pub(crate) fn extract(ext: &Extensions) -> Result { + let inner = IdentityInner::extract(ext); + inner.get_identity()?; + Ok(Self(inner)) + } + + pub(crate) fn logged_at(&self) -> Result, anyhow::Error> { + self.0 + .session + .get(LOGIN_UNIX_TIMESTAMP_KEY)? + .map(OffsetDateTime::from_unix_timestamp) + .transpose() + .map_err(anyhow::Error::from) + } + + pub(crate) fn last_visited_at(&self) -> Result, anyhow::Error> { + self.0 + .session + .get(LAST_VISIT_UNIX_TIMESTAMP_KEY)? + .map(OffsetDateTime::from_unix_timestamp) + .transpose() + .map_err(anyhow::Error::from) + } + + pub(crate) fn set_last_visited_at(&self) -> Result<(), anyhow::Error> { + let now = OffsetDateTime::now_utc().unix_timestamp(); + self.0.session.insert(LAST_VISIT_UNIX_TIMESTAMP_KEY, now)?; + Ok(()) + } +} + +/// Extractor implementation for [`Identity`]. +/// +/// # Examples +/// ``` +/// use actix_web::{get, Responder}; +/// use actix_identity::Identity; +/// +/// #[get("/")] +/// async fn index(user: Option) -> impl Responder { +/// if let Some(user) = user { +/// format!("Welcome! {}", user.id().unwrap()) /// } else { /// "Welcome Anonymous!".to_owned() /// } @@ -91,10 +249,17 @@ impl Identity { /// ``` impl FromRequest for Identity { type Error = Error; - type Future = Ready>; + type Future = Ready>; #[inline] fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { - ready(Ok(Identity(req.clone()))) + ready(Identity::extract(&req.extensions()).map_err(|err| { + let res = actix_web::error::InternalError::from_response( + err, + HttpResponse::new(StatusCode::UNAUTHORIZED), + ); + + actix_web::Error::from(res) + })) } } diff --git a/actix-identity/src/identity_ext.rs b/actix-identity/src/identity_ext.rs new file mode 100644 index 000000000..431539a86 --- /dev/null +++ b/actix-identity/src/identity_ext.rs @@ -0,0 +1,27 @@ +use actix_web::{dev::ServiceRequest, guard::GuardContext, HttpMessage, HttpRequest}; + +use crate::Identity; + +/// Helper trait to retrieve an [`Identity`] instance from various `actix-web`'s types. +pub trait IdentityExt { + /// Retrieve the identity attached to the current session, if available. + fn get_identity(&self) -> Result; +} + +impl IdentityExt for HttpRequest { + fn get_identity(&self) -> Result { + Identity::extract(&self.extensions()) + } +} + +impl IdentityExt for ServiceRequest { + fn get_identity(&self) -> Result { + Identity::extract(&self.extensions()) + } +} + +impl<'a> IdentityExt for GuardContext<'a> { + fn get_identity(&self) -> Result { + Identity::extract(&self.req_data()) + } +} diff --git a/actix-identity/src/lib.rs b/actix-identity/src/lib.rs index 2e51fd365..9ef232a52 100644 --- a/actix-identity/src/lib.rs +++ b/actix-identity/src/lib.rs @@ -1,163 +1,100 @@ -//! Opinionated request identity service for Actix Web apps. +//! Identity management for Actix Web. //! -//! [`IdentityService`] middleware can be used with different policies types to store -//! identity information. +//! `actix-identity` can be used to track identity of a user across multiple requests. It is built +//! on top of HTTP sessions, via [`actix-session`](https://docs.rs/actix-session). //! -//! A cookie based policy is provided. [`CookieIdentityPolicy`] uses cookies as identity storage. +//! # Getting started +//! To start using identity management in your Actix Web application you must register +//! [`IdentityMiddleware`] and `SessionMiddleware` as middleware on your `App`: //! -//! To access current request identity, use the [`Identity`] extractor. +//! ```no_run +//! # use actix_web::web; +//! use actix_web::{cookie::Key, App, HttpServer, HttpResponse}; +//! use actix_identity::IdentityMiddleware; +//! use actix_session::{storage::RedisSessionStore, SessionMiddleware}; //! +//! #[actix_web::main] +//! async fn main() { +//! let secret_key = Key::generate(); +//! let redis_store = RedisSessionStore::new("redis://127.0.0.1:6379") +//! .await +//! .unwrap(); +//! +//! HttpServer::new(move || { +//! App::new() +//! // Install the identity framework first. +//! .wrap(IdentityMiddleware::default()) +//! // The identity system is built on top of sessions. You must install the session +//! // middleware to leverage `actix-identity`. The session middleware must be mounted +//! // AFTER the identity middleware: `actix-web` invokes middleware in the OPPOSITE +//! // order of registration when it receives an incoming request. +//! .wrap(SessionMiddleware::new( +//! redis_store.clone(), +//! secret_key.clone() +//! )) +//! // Your request handlers [...] +//! # .default_service(web::to(|| HttpResponse::Ok())) +//! }) +//! # ; +//! } //! ``` -//! use actix_web::*; -//! use actix_identity::{Identity, CookieIdentityPolicy, IdentityService}; +//! +//! User identities can be created, accessed and destroyed using the [`Identity`] extractor in your +//! request handlers: +//! +//! ```no_run +//! use actix_web::{get, post, HttpResponse, Responder, HttpRequest, HttpMessage}; +//! use actix_identity::Identity; +//! use actix_session::storage::RedisSessionStore; //! //! #[get("/")] -//! async fn index(id: Identity) -> String { -//! // access request identity -//! if let Some(id) = id.identity() { -//! format!("Welcome! {}", id) +//! async fn index(user: Option) -> impl Responder { +//! if let Some(user) = user { +//! format!("Welcome! {}", user.id().unwrap()) //! } else { //! "Welcome Anonymous!".to_owned() //! } //! } //! //! #[post("/login")] -//! async fn login(id: Identity) -> HttpResponse { -//! // remember identity -//! id.remember("User1".to_owned()); -//! HttpResponse::Ok().finish() +//! async fn login(request: HttpRequest) -> impl Responder { +//! // Some kind of authentication should happen here +//! // e.g. password-based, biometric, etc. +//! // [...] +//! +//! // attach a verified user identity to the active session +//! Identity::login(&request.extensions(), "User1".into()).unwrap(); +//! +//! HttpResponse::Ok() //! } //! //! #[post("/logout")] -//! async fn logout(id: Identity) -> HttpResponse { -//! // remove identity -//! id.forget(); -//! HttpResponse::Ok().finish() +//! async fn logout(user: Identity) -> impl Responder { +//! user.logout(); +//! HttpResponse::Ok() //! } -//! -//! HttpServer::new(move || { -//! // create cookie identity backend (inside closure, since policy is not Clone) -//! let policy = CookieIdentityPolicy::new(&[0; 32]) -//! .name("auth-cookie") -//! .secure(false); -//! -//! App::new() -//! // wrap policy into middleware identity middleware -//! .wrap(IdentityService::new(policy)) -//! .service(services![index, login, logout]) -//! }) -//! # ; //! ``` +//! +//! # Advanced configuration +//! By default, `actix-identity` does not automatically log out users. You can change this behaviour +//! by customising the configuration for [`IdentityMiddleware`] via [`IdentityMiddleware::builder`]. +//! +//! In particular, you can automatically log out users who: +//! - have been inactive for a while (see [`IdentityMiddlewareBuilder::visit_deadline`]; +//! - logged in too long ago (see [`IdentityMiddlewareBuilder::login_deadline`]). +//! +//! [`IdentityMiddlewareBuilder::visit_deadline`]: config::IdentityMiddlewareBuilder::visit_deadline +//! [`IdentityMiddlewareBuilder::login_deadline`]: config::IdentityMiddlewareBuilder::login_deadline -#![deny(rust_2018_idioms, nonstandard_style)] +#![forbid(unsafe_code)] +#![deny(rust_2018_idioms, nonstandard_style, missing_docs)] #![warn(future_incompatible)] -use std::future::Future; - -use actix_web::{ - dev::{ServiceRequest, ServiceResponse}, - Error, HttpMessage, Result, -}; - -mod cookie; +pub mod config; mod identity; +mod identity_ext; mod middleware; -pub use self::cookie::CookieIdentityPolicy; pub use self::identity::Identity; -pub use self::middleware::IdentityService; - -/// Identity policy. -pub trait IdentityPolicy: Sized + 'static { - /// The return type of the middleware - type Future: Future, Error>>; - - /// The return type of the middleware - type ResponseFuture: Future>; - - /// Parse the session from request and load data from a service identity. - #[allow(clippy::wrong_self_convention)] - fn from_request(&self, req: &mut ServiceRequest) -> Self::Future; - - /// Write changes to response - fn to_response( - &self, - identity: Option, - changed: bool, - response: &mut ServiceResponse, - ) -> Self::ResponseFuture; -} - -/// Helper trait that allows to get Identity. -/// -/// It could be used in middleware but identity policy must be set before any other middleware that -/// needs identity. RequestIdentity is implemented both for `ServiceRequest` and `HttpRequest`. -pub trait RequestIdentity { - fn get_identity(&self) -> Option; -} - -impl RequestIdentity for T -where - T: HttpMessage, -{ - fn get_identity(&self) -> Option { - Identity::get_identity(&self.extensions()) - } -} - -#[cfg(test)] -mod tests { - use std::time::SystemTime; - - use actix_web::{ - body::{BoxBody, EitherBody}, - dev::ServiceResponse, - test, web, App, Error, - }; - - use super::*; - - pub(crate) const COOKIE_KEY_MASTER: [u8; 32] = [0; 32]; - pub(crate) const COOKIE_NAME: &str = "actix_auth"; - pub(crate) const COOKIE_LOGIN: &str = "test"; - - #[allow(clippy::enum_variant_names)] - pub(crate) enum LoginTimestampCheck { - NoTimestamp, - NewTimestamp, - OldTimestamp(SystemTime), - } - - #[allow(clippy::enum_variant_names)] - pub(crate) enum VisitTimeStampCheck { - NoTimestamp, - NewTimestamp, - } - - pub(crate) async fn create_identity_server< - F: Fn(CookieIdentityPolicy) -> CookieIdentityPolicy + Sync + Send + Clone + 'static, - >( - f: F, - ) -> impl actix_service::Service< - actix_http::Request, - Response = ServiceResponse>, - Error = Error, - > { - test::init_service( - App::new() - .wrap(IdentityService::new(f(CookieIdentityPolicy::new( - &COOKIE_KEY_MASTER, - ) - .secure(false) - .name(COOKIE_NAME)))) - .service(web::resource("/").to(|id: Identity| async move { - let identity = id.identity(); - if identity.is_none() { - id.remember(COOKIE_LOGIN.to_string()) - } - web::Json(identity) - })), - ) - .await - } -} +pub use self::identity_ext::IdentityExt; +pub use self::middleware::IdentityMiddleware; diff --git a/actix-identity/src/middleware.rs b/actix-identity/src/middleware.rs index 9e5deb2b2..893e623f6 100644 --- a/actix-identity/src/middleware.rs +++ b/actix-identity/src/middleware.rs @@ -1,171 +1,258 @@ use std::rc::Rc; +use actix_session::SessionExt; use actix_utils::future::{ready, Ready}; use actix_web::{ - body::{EitherBody, MessageBody}, + body::MessageBody, + cookie::time::{format_description::well_known::Rfc3339, OffsetDateTime}, dev::{Service, ServiceRequest, ServiceResponse, Transform}, - Error, HttpMessage, Result, + Error, HttpMessage as _, Result, }; -use futures_util::future::{FutureExt as _, LocalBoxFuture}; +use futures_core::future::LocalBoxFuture; -use crate::{identity::IdentityItem, IdentityPolicy}; +use crate::{ + config::{Configuration, IdentityMiddlewareBuilder}, + identity::IdentityInner, + Identity, +}; -/// Request identity middleware +/// Identity management middleware. /// +/// ```no_run +/// use actix_web::{cookie::Key, App, HttpServer}; +/// use actix_session::storage::RedisSessionStore; +/// use actix_identity::{Identity, IdentityMiddleware}; +/// use actix_session::{Session, SessionMiddleware}; +/// +/// #[actix_web::main] +/// async fn main() { +/// let secret_key = Key::generate(); +/// let redis_store = RedisSessionStore::new("redis://127.0.0.1:6379").await.unwrap(); +/// +/// HttpServer::new(move || { +/// App::new() +/// // Install the identity framework first. +/// .wrap(IdentityMiddleware::default()) +/// // The identity system is built on top of sessions. +/// // You must install the session middleware to leverage `actix-identity`. +/// .wrap(SessionMiddleware::new(redis_store.clone(), secret_key.clone())) +/// }) +/// # ; +/// } /// ``` -/// use actix_web::App; -/// use actix_identity::{CookieIdentityPolicy, IdentityService}; -/// -/// // create cookie identity backend -/// let policy = CookieIdentityPolicy::new(&[0; 32]) -/// .name("auth-cookie") -/// .secure(false); -/// -/// let app = App::new() -/// // wrap policy into identity middleware -/// .wrap(IdentityService::new(policy)); -/// ``` -pub struct IdentityService { - backend: Rc, +#[derive(Default, Clone)] +pub struct IdentityMiddleware { + configuration: Rc, } -impl IdentityService { - /// Create new identity service with specified backend. - pub fn new(backend: T) -> Self { - IdentityService { - backend: Rc::new(backend), +impl IdentityMiddleware { + pub(crate) fn new(configuration: Configuration) -> Self { + Self { + configuration: Rc::new(configuration), } } + + /// A fluent API to configure [`IdentityMiddleware`]. + pub fn builder() -> IdentityMiddlewareBuilder { + IdentityMiddlewareBuilder::new() + } } -impl Transform for IdentityService +impl Transform for IdentityMiddleware where S: Service, Error = Error> + 'static, S::Future: 'static, - T: IdentityPolicy, B: MessageBody + 'static, { - type Response = ServiceResponse>; + type Response = ServiceResponse; type Error = Error; + type Transform = InnerIdentityMiddleware; type InitError = (); - type Transform = IdentityServiceMiddleware; type Future = Ready>; fn new_transform(&self, service: S) -> Self::Future { - ready(Ok(IdentityServiceMiddleware { - backend: self.backend.clone(), + ready(Ok(InnerIdentityMiddleware { service: Rc::new(service), + configuration: Rc::clone(&self.configuration), })) } } -pub struct IdentityServiceMiddleware { - pub(crate) service: Rc, - pub(crate) backend: Rc, +#[doc(hidden)] +pub struct InnerIdentityMiddleware { + service: Rc, + configuration: Rc, } -impl Clone for IdentityServiceMiddleware { +impl Clone for InnerIdentityMiddleware { fn clone(&self) -> Self { Self { - backend: Rc::clone(&self.backend), service: Rc::clone(&self.service), + configuration: Rc::clone(&self.configuration), } } } -impl Service for IdentityServiceMiddleware +impl Service for InnerIdentityMiddleware where S: Service, Error = Error> + 'static, S::Future: 'static, - T: IdentityPolicy, B: MessageBody + 'static, { - type Response = ServiceResponse>; + type Response = ServiceResponse; type Error = Error; type Future = LocalBoxFuture<'static, Result>; actix_service::forward_ready!(service); - fn call(&self, mut req: ServiceRequest) -> Self::Future { + fn call(&self, req: ServiceRequest) -> Self::Future { let srv = Rc::clone(&self.service); - let backend = Rc::clone(&self.backend); - let fut = self.backend.from_request(&mut req); - - async move { - match fut.await { - Ok(id) => { - req.extensions_mut() - .insert(IdentityItem { id, changed: false }); - - let mut res = srv.call(req).await?; - let id = res.request().extensions_mut().remove::(); - - if let Some(id) = id { - match backend.to_response(id.id, id.changed, &mut res).await { - Ok(_) => Ok(res.map_into_left_body()), - Err(err) => Ok(res.error_response(err).map_into_right_body()), - } - } else { - Ok(res.map_into_left_body()) - } - } - Err(err) => Ok(req.error_response(err).map_into_right_body()), - } - } - .boxed_local() + let configuration = Rc::clone(&self.configuration); + Box::pin(async move { + let identity_inner = IdentityInner { + session: req.get_session(), + logout_behaviour: configuration.on_logout.clone(), + is_login_deadline_enabled: configuration.login_deadline.is_some(), + is_visit_deadline_enabled: configuration.visit_deadline.is_some(), + }; + req.extensions_mut().insert(identity_inner); + enforce_policies(&req, &configuration); + srv.call(req).await + }) } } -#[cfg(test)] -mod tests { - use std::{rc::Rc, time::Duration}; +// easier to scan with returns where they are +// especially if the function body were to evolve in the future +#[allow(clippy::needless_return)] +fn enforce_policies(req: &ServiceRequest, configuration: &Configuration) { + let must_extract_identity = + configuration.login_deadline.is_some() || configuration.visit_deadline.is_some(); - use actix_service::into_service; - use actix_web::{dev, error, test, Error, Result}; + if !must_extract_identity { + return; + } - use super::*; + let identity = match Identity::extract(&req.extensions()) { + Ok(identity) => identity, + Err(err) => { + tracing::debug!( + error.display = %err, + error.debug = ?err, + "Failed to extract an `Identity` from the incoming request." + ); + return; + } + }; - #[actix_web::test] - async fn test_borrowed_mut_error() { - use actix_utils::future::{ok, Ready}; - use futures_util::future::lazy; + if let Some(login_deadline) = configuration.login_deadline { + if matches!( + enforce_login_deadline(&identity, login_deadline), + PolicyDecision::LogOut + ) { + identity.logout(); + return; + } + } - struct Ident; - impl IdentityPolicy for Ident { - type Future = Ready, Error>>; - type ResponseFuture = Ready>; - - fn from_request(&self, _: &mut dev::ServiceRequest) -> Self::Future { - ok(Some("test".to_string())) - } - - fn to_response( - &self, - _: Option, - _: bool, - _: &mut dev::ServiceResponse, - ) -> Self::ResponseFuture { - ok(()) + if let Some(visit_deadline) = configuration.visit_deadline { + if matches!( + enforce_visit_deadline(&identity, visit_deadline), + PolicyDecision::LogOut + ) { + identity.logout(); + return; + } else { + if let Err(err) = identity.set_last_visited_at() { + tracing::warn!( + error.display = %err, + error.debug = ?err, + "Failed to set the last visited timestamp on `Identity` for an incoming request." + ); } } - - let srv = crate::middleware::IdentityServiceMiddleware { - backend: Rc::new(Ident), - service: Rc::new(into_service(|_: dev::ServiceRequest| async move { - actix_web::rt::time::sleep(Duration::from_secs(100)).await; - Err::(error::ErrorBadRequest("error")) - })), - }; - - let srv2 = srv.clone(); - let req = test::TestRequest::default().to_srv_request(); - - actix_web::rt::spawn(async move { - let _ = srv2.call(req).await; - }); - - actix_web::rt::time::sleep(Duration::from_millis(50)).await; - - let _ = lazy(|cx| srv.poll_ready(cx)).await; } } + +fn enforce_login_deadline( + identity: &Identity, + login_deadline: std::time::Duration, +) -> PolicyDecision { + match identity.logged_at() { + Ok(None) => { + tracing::info!( + "Login deadline is enabled, but there is no login timestamp in the session \ + state attached to the incoming request. Logging the user out." + ); + PolicyDecision::LogOut + } + Err(err) => { + tracing::info!( + error.display = %err, + error.debug = ?err, + "Login deadline is enabled but we failed to extract the login timestamp from the \ + session state attached to the incoming request. Logging the user out." + ); + PolicyDecision::LogOut + } + Ok(Some(logged_in_at)) => { + let elapsed = OffsetDateTime::now_utc() - logged_in_at; + if elapsed > login_deadline { + tracing::info!( + user.logged_in_at = %logged_in_at.format(&Rfc3339).unwrap_or_default(), + identity.login_deadline_seconds = login_deadline.as_secs(), + identity.elapsed_since_login_seconds = elapsed.whole_seconds(), + "Login deadline is enabled and too much time has passed since the user logged \ + in. Logging the user out." + ); + PolicyDecision::LogOut + } else { + PolicyDecision::StayLoggedIn + } + } + } +} + +fn enforce_visit_deadline( + identity: &Identity, + visit_deadline: std::time::Duration, +) -> PolicyDecision { + match identity.last_visited_at() { + Ok(None) => { + tracing::info!( + "Last visit deadline is enabled, but there is no last visit timestamp in the \ + session state attached to the incoming request. Logging the user out." + ); + PolicyDecision::LogOut + } + Err(err) => { + tracing::info!( + error.display = %err, + error.debug = ?err, + "Last visit deadline is enabled but we failed to extract the last visit timestamp \ + from the session state attached to the incoming request. Logging the user out." + ); + PolicyDecision::LogOut + } + Ok(Some(last_visited_at)) => { + let elapsed = OffsetDateTime::now_utc() - last_visited_at; + if elapsed > visit_deadline { + tracing::info!( + user.last_visited_at = %last_visited_at.format(&Rfc3339).unwrap_or_default(), + identity.visit_deadline_seconds = visit_deadline.as_secs(), + identity.elapsed_since_last_visit_seconds = elapsed.whole_seconds(), + "Last visit deadline is enabled and too much time has passed since the last \ + time the user visited. Logging the user out." + ); + PolicyDecision::LogOut + } else { + PolicyDecision::StayLoggedIn + } + } + } +} + +enum PolicyDecision { + StayLoggedIn, + LogOut, +} diff --git a/actix-identity/tests/integration/fixtures.rs b/actix-identity/tests/integration/fixtures.rs new file mode 100644 index 000000000..f3fce595b --- /dev/null +++ b/actix-identity/tests/integration/fixtures.rs @@ -0,0 +1,17 @@ +use actix_session::{storage::CookieSessionStore, SessionMiddleware}; +use actix_web::cookie::Key; +use uuid::Uuid; + +pub fn store() -> CookieSessionStore { + CookieSessionStore::default() +} + +pub fn user_id() -> String { + Uuid::new_v4().to_string() +} + +pub fn session_middleware() -> SessionMiddleware { + SessionMiddleware::builder(store(), Key::generate()) + .cookie_domain(Some("localhost".into())) + .build() +} diff --git a/actix-identity/tests/integration/integration.rs b/actix-identity/tests/integration/integration.rs new file mode 100644 index 000000000..9753d4e2d --- /dev/null +++ b/actix-identity/tests/integration/integration.rs @@ -0,0 +1,185 @@ +use std::time::Duration; + +use actix_identity::{config::LogoutBehaviour, IdentityMiddleware}; +use actix_web::http::StatusCode; + +use crate::{fixtures::user_id, test_app::TestApp}; + +#[actix_web::test] +async fn opaque_401_is_returned_for_unauthenticated_users() { + let app = TestApp::spawn(); + + let response = app.get_identity_required().await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!(response.bytes().await.unwrap().is_empty()); +} + +#[actix_web::test] +async fn login_works() { + let app = TestApp::spawn(); + let user_id = user_id(); + + // Log-in + let body = app.post_login(user_id.clone()).await; + assert_eq!(body.user_id, Some(user_id.clone())); + + // Access identity-restricted route successfully + let response = app.get_identity_required().await; + assert!(response.status().is_success()); +} + +#[actix_web::test] +async fn logging_in_again_replaces_the_current_identity() { + let app = TestApp::spawn(); + let first_user_id = user_id(); + let second_user_id = user_id(); + + // Log-in + let body = app.post_login(first_user_id.clone()).await; + assert_eq!(body.user_id, Some(first_user_id.clone())); + + // Log-in again + let body = app.post_login(second_user_id.clone()).await; + assert_eq!(body.user_id, Some(second_user_id.clone())); + + let body = app.get_current().await; + assert_eq!(body.user_id, Some(second_user_id.clone())); +} + +#[actix_web::test] +async fn session_key_is_renewed_on_login() { + let app = TestApp::spawn(); + let user_id = user_id(); + + // Create an anonymous session + let body = app.post_increment().await; + assert_eq!(body.user_id, None); + assert_eq!(body.counter, 1); + assert_eq!(body.session_status, "changed"); + + // Log-in + let body = app.post_login(user_id.clone()).await; + assert_eq!(body.user_id, Some(user_id.clone())); + assert_eq!(body.counter, 1); + assert_eq!(body.session_status, "renewed"); +} + +#[actix_web::test] +async fn logout_works() { + let app = TestApp::spawn(); + let user_id = user_id(); + + // Log-in + let body = app.post_login(user_id.clone()).await; + assert_eq!(body.user_id, Some(user_id.clone())); + + // Log-out + let response = app.post_logout().await; + assert!(response.status().is_success()); + + // Try to access identity-restricted route + let response = app.get_identity_required().await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[actix_web::test] +async fn logout_can_avoid_destroying_the_whole_session() { + let app = TestApp::spawn_with_config( + IdentityMiddleware::builder().logout_behaviour(LogoutBehaviour::DeleteIdentityKeys), + ); + let user_id = user_id(); + + // Log-in + let body = app.post_login(user_id.clone()).await; + assert_eq!(body.user_id, Some(user_id.clone())); + assert_eq!(body.counter, 0); + + // Increment counter + let body = app.post_increment().await; + assert_eq!(body.user_id, Some(user_id.clone())); + assert_eq!(body.counter, 1); + + // Log-out + let response = app.post_logout().await; + assert!(response.status().is_success()); + + // Check the state of the counter attached to the session state + let body = app.get_current().await; + assert_eq!(body.user_id, None); + // It would be 0 if the session state had been entirely lost! + assert_eq!(body.counter, 1); +} + +#[actix_web::test] +async fn user_is_logged_out_when_login_deadline_is_elapsed() { + let login_deadline = Duration::from_millis(10); + let app = TestApp::spawn_with_config( + IdentityMiddleware::builder().login_deadline(Some(login_deadline)), + ); + let user_id = user_id(); + + // Log-in + let body = app.post_login(user_id.clone()).await; + assert_eq!(body.user_id, Some(user_id.clone())); + + // Wait for deadline to pass + actix_web::rt::time::sleep(login_deadline * 2).await; + + let body = app.get_current().await; + // We have been logged out! + assert_eq!(body.user_id, None); +} + +#[actix_web::test] +async fn login_deadline_does_not_log_users_out_before_their_time() { + // 1 hour + let login_deadline = Duration::from_secs(60 * 60); + let app = TestApp::spawn_with_config( + IdentityMiddleware::builder().login_deadline(Some(login_deadline)), + ); + let user_id = user_id(); + + // Log-in + let body = app.post_login(user_id.clone()).await; + assert_eq!(body.user_id, Some(user_id.clone())); + + let body = app.get_current().await; + assert_eq!(body.user_id, Some(user_id)); +} + +#[actix_web::test] +async fn visit_deadline_does_not_log_users_out_before_their_time() { + // 1 hour + let visit_deadline = Duration::from_secs(60 * 60); + let app = TestApp::spawn_with_config( + IdentityMiddleware::builder().visit_deadline(Some(visit_deadline)), + ); + let user_id = user_id(); + + // Log-in + let body = app.post_login(user_id.clone()).await; + assert_eq!(body.user_id, Some(user_id.clone())); + + let body = app.get_current().await; + assert_eq!(body.user_id, Some(user_id)); +} + +#[actix_web::test] +async fn user_is_logged_out_when_visit_deadline_is_elapsed() { + let visit_deadline = Duration::from_millis(10); + let app = TestApp::spawn_with_config( + IdentityMiddleware::builder().visit_deadline(Some(visit_deadline)), + ); + let user_id = user_id(); + + // Log-in + let body = app.post_login(user_id.clone()).await; + assert_eq!(body.user_id, Some(user_id.clone())); + + // Wait for deadline to pass + actix_web::rt::time::sleep(visit_deadline * 2).await; + + let body = app.get_current().await; + // We have been logged out! + assert_eq!(body.user_id, None); +} diff --git a/actix-identity/tests/integration/main.rs b/actix-identity/tests/integration/main.rs new file mode 100644 index 000000000..8ebd2e86a --- /dev/null +++ b/actix-identity/tests/integration/main.rs @@ -0,0 +1,3 @@ +pub mod fixtures; +mod integration; +pub mod test_app; diff --git a/actix-identity/tests/integration/test_app.rs b/actix-identity/tests/integration/test_app.rs new file mode 100644 index 000000000..93f41b85e --- /dev/null +++ b/actix-identity/tests/integration/test_app.rs @@ -0,0 +1,186 @@ +use std::net::TcpListener; + +use actix_identity::{config::IdentityMiddlewareBuilder, Identity, IdentityMiddleware}; +use actix_session::{Session, SessionStatus}; +use actix_web::{web, App, HttpMessage, HttpRequest, HttpResponse, HttpServer}; +use serde::{Deserialize, Serialize}; + +use crate::fixtures::session_middleware; + +pub struct TestApp { + port: u16, + api_client: reqwest::Client, +} + +impl TestApp { + /// Spawn a test application using a custom configuration for `IdentityMiddleware`. + pub fn spawn_with_config(builder: IdentityMiddlewareBuilder) -> Self { + // Random OS port + let listener = TcpListener::bind("localhost:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = HttpServer::new(move || { + App::new() + .wrap(builder.clone().build()) + .wrap(session_middleware()) + .route("/increment", web::post().to(increment)) + .route("/current", web::get().to(show)) + .route("/login", web::post().to(login)) + .route("/logout", web::post().to(logout)) + .route("/identity_required", web::get().to(identity_required)) + }) + .workers(1) + .listen(listener) + .unwrap() + .run(); + let _ = actix_web::rt::spawn(server); + + let client = reqwest::Client::builder() + .cookie_store(true) + .build() + .unwrap(); + + TestApp { + port, + api_client: client, + } + } + + /// Spawn a test application using the default configuration settings for `IdentityMiddleware`. + pub fn spawn() -> Self { + Self::spawn_with_config(IdentityMiddleware::builder()) + } + + fn url(&self) -> String { + format!("http://localhost:{}", self.port) + } + + pub async fn get_identity_required(&self) -> reqwest::Response { + self.api_client + .get(format!("{}/identity_required", &self.url())) + .send() + .await + .unwrap() + } + + pub async fn get_current(&self) -> EndpointResponse { + self.api_client + .get(format!("{}/current", &self.url())) + .send() + .await + .unwrap() + .json() + .await + .unwrap() + } + + pub async fn post_increment(&self) -> EndpointResponse { + let response = self + .api_client + .post(format!("{}/increment", &self.url())) + .send() + .await + .unwrap(); + response.json().await.unwrap() + } + + pub async fn post_login(&self, user_id: String) -> EndpointResponse { + let response = self + .api_client + .post(format!("{}/login", &self.url())) + .json(&LoginRequest { user_id }) + .send() + .await + .unwrap(); + response.json().await.unwrap() + } + + pub async fn post_logout(&self) -> reqwest::Response { + self.api_client + .post(format!("{}/logout", &self.url())) + .send() + .await + .unwrap() + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct EndpointResponse { + pub user_id: Option, + pub counter: i32, + pub session_status: String, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +struct LoginRequest { + user_id: String, +} + +async fn show(user: Option, session: Session) -> HttpResponse { + let user_id = user.map(|u| u.id().unwrap()); + let counter: i32 = session + .get::("counter") + .unwrap_or(Some(0)) + .unwrap_or(0); + + HttpResponse::Ok().json(&EndpointResponse { + user_id, + counter, + session_status: session_status(session), + }) +} + +async fn increment(session: Session, user: Option) -> HttpResponse { + let user_id = user.map(|u| u.id().unwrap()); + let counter: i32 = session + .get::("counter") + .unwrap_or(Some(0)) + .map_or(1, |inner| inner + 1); + session.insert("counter", &counter).unwrap(); + + HttpResponse::Ok().json(&EndpointResponse { + user_id, + counter, + session_status: session_status(session), + }) +} + +async fn login( + user_id: web::Json, + request: HttpRequest, + session: Session, +) -> HttpResponse { + let id = user_id.into_inner().user_id; + let user = Identity::login(&request.extensions(), id).unwrap(); + + let counter: i32 = session + .get::("counter") + .unwrap_or(Some(0)) + .unwrap_or(0); + + HttpResponse::Ok().json(&EndpointResponse { + user_id: Some(user.id().unwrap()), + counter, + session_status: session_status(session), + }) +} + +async fn logout(user: Option) -> HttpResponse { + if let Some(user) = user { + user.logout(); + } + HttpResponse::Ok().finish() +} + +async fn identity_required(_identity: Identity) -> HttpResponse { + HttpResponse::Ok().finish() +} + +fn session_status(session: Session) -> String { + match session.status() { + SessionStatus::Changed => "changed", + SessionStatus::Purged => "purged", + SessionStatus::Renewed => "renewed", + SessionStatus::Unchanged => "unchanged", + } + .into() +} diff --git a/actix-limitation/CHANGES.md b/actix-limitation/CHANGES.md index 4070cb196..83287babd 100644 --- a/actix-limitation/CHANGES.md +++ b/actix-limitation/CHANGES.md @@ -3,6 +3,12 @@ ## Unreleased - 2022-xx-xx +## 0.3.0 - 2022-07-11 +- `Limiter::builder` now takes an `impl Into`. +- Removed lifetime from `Builder`. +- Updated `actix-session` dependency to `0.7`. + + ## 0.2.0 - 2022-03-22 - Update Actix Web dependency to v4 ecosystem. [#229] - Update Tokio dependencies to v1 ecosystem. [#229] diff --git a/actix-limitation/Cargo.toml b/actix-limitation/Cargo.toml index 912b7c161..ce5da8197 100644 --- a/actix-limitation/Cargo.toml +++ b/actix-limitation/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "actix-limitation" -version = "0.2.0" +version = "0.3.0" authors = [ "0xmad <0xmad@users.noreply.github.com>", "Rob Ede ", @@ -13,7 +13,7 @@ license = "MIT OR Apache-2.0" edition = "2018" [dependencies] -actix-session = "0.5" +actix-session = "0.7" actix-utils = "3" actix-web = { version = "4", default-features = false } @@ -25,5 +25,5 @@ time = "0.3" [dev-dependencies] actix-web = "4" -uuid = { version = "0.8", features = ["v4"] } static_assertions = "1" +uuid = { version = "1", features = ["v4"] } diff --git a/actix-limitation/README.md b/actix-limitation/README.md index 309f34b70..007ef4634 100644 --- a/actix-limitation/README.md +++ b/actix-limitation/README.md @@ -4,16 +4,16 @@ > Originally based on . [![crates.io](https://img.shields.io/crates/v/actix-limitation?label=latest)](https://crates.io/crates/actix-limitation) -[![Documentation](https://docs.rs/actix-limitation/badge.svg?version=0.2.0)](https://docs.rs/actix-limitation/0.2.0) +[![Documentation](https://docs.rs/actix-limitation/badge.svg?version=0.3.0)](https://docs.rs/actix-limitation/0.3.0) ![Apache 2.0 or MIT licensed](https://img.shields.io/crates/l/actix-limitation) -[![Dependency Status](https://deps.rs/crate/actix-limitation/0.2.0/status.svg)](https://deps.rs/crate/actix-limitation/0.2.0) +[![Dependency Status](https://deps.rs/crate/actix-limitation/0.3.0/status.svg)](https://deps.rs/crate/actix-limitation/0.3.0) ## Examples ```toml [dependencies] actix-web = "4" -actix-limitation = "0.1.4" +actix-limitation = "0.3" ``` ```rust diff --git a/actix-limitation/src/builder.rs b/actix-limitation/src/builder.rs index 99072fe2d..3bc316037 100644 --- a/actix-limitation/src/builder.rs +++ b/actix-limitation/src/builder.rs @@ -4,17 +4,17 @@ use redis::Client; use crate::{errors::Error, Limiter}; -/// Rate limit builder. +/// Rate limiter builder. #[derive(Debug)] -pub struct Builder<'a> { - pub(crate) redis_url: &'a str, +pub struct Builder { + pub(crate) redis_url: String, pub(crate) limit: usize, pub(crate) period: Duration, pub(crate) cookie_name: Cow<'static, str>, pub(crate) session_key: Cow<'static, str>, } -impl Builder<'_> { +impl Builder { /// Set upper limit. pub fn limit(&mut self, limit: usize) -> &mut Self { self.limit = limit; @@ -45,7 +45,7 @@ impl Builder<'_> { /// **synchronous** operation. pub fn build(&self) -> Result { Ok(Limiter { - client: Client::open(self.redis_url)?, + client: Client::open(self.redis_url.as_str())?, limit: self.limit, period: self.period, cookie_name: self.cookie_name.clone(), @@ -63,7 +63,7 @@ mod tests { let redis_url = "redis://127.0.0.1"; let period = Duration::from_secs(10); let builder = Builder { - redis_url, + redis_url: redis_url.to_owned(), limit: 100, period, cookie_name: Cow::Owned("session".to_string()), @@ -82,7 +82,7 @@ mod tests { let redis_url = "redis://127.0.0.1"; let period = Duration::from_secs(20); let mut builder = Builder { - redis_url, + redis_url: redis_url.to_owned(), limit: 100, period: Duration::from_secs(10), session_key: Cow::Borrowed("key"), @@ -109,7 +109,7 @@ mod tests { let redis_url = "127.0.0.1"; let period = Duration::from_secs(20); let mut builder = Builder { - redis_url, + redis_url: redis_url.to_owned(), limit: 100, period: Duration::from_secs(10), session_key: Cow::Borrowed("key"), diff --git a/actix-limitation/src/lib.rs b/actix-limitation/src/lib.rs index a3cb9363a..7f160a309 100644 --- a/actix-limitation/src/lib.rs +++ b/actix-limitation/src/lib.rs @@ -3,7 +3,7 @@ //! ```toml //! [dependencies] //! actix-web = "4" -//! actix-limitation = "0.1.4" +#![doc = concat!("actix-limitation = \"", env!("CARGO_PKG_VERSION_MAJOR"), ".", env!("CARGO_PKG_VERSION_MINOR"),"\"")] //! ``` //! //! ```no_run @@ -34,7 +34,7 @@ //! .app_data(limiter.clone()) //! .service(index) //! }) -//! .bind("127.0.0.1:8080")? +//! .bind(("127.0.0.1", 8080))? //! .run() //! .await //! } @@ -73,7 +73,7 @@ pub const DEFAULT_COOKIE_NAME: &str = "sid"; pub const DEFAULT_SESSION_KEY: &str = "rate-api-id"; /// Rate limiter. -#[derive(Clone, Debug)] +#[derive(Debug, Clone)] pub struct Limiter { client: Client, limit: usize, @@ -88,9 +88,9 @@ impl Limiter { /// See [`redis-rs` docs](https://docs.rs/redis/0.21/redis/#connection-parameters) on connection /// parameters for how to set the Redis URL. #[must_use] - pub fn builder(redis_url: &str) -> Builder<'_> { + pub fn builder(redis_url: impl Into) -> Builder { Builder { - redis_url, + redis_url: redis_url.into(), limit: DEFAULT_REQUEST_LIMIT, period: Duration::from_secs(DEFAULT_PERIOD_SECS), cookie_name: Cow::Borrowed(DEFAULT_COOKIE_NAME), diff --git a/actix-limitation/src/middleware.rs b/actix-limitation/src/middleware.rs index cf46cddca..2acdb776a 100644 --- a/actix-limitation/src/middleware.rs +++ b/actix-limitation/src/middleware.rs @@ -1,6 +1,6 @@ use std::{future::Future, pin::Pin, rc::Rc}; -use actix_session::UserSession; +use actix_session::SessionExt as _; use actix_utils::future::{ok, Ready}; use actix_web::{ body::EitherBody, diff --git a/actix-protobuf/CHANGES.md b/actix-protobuf/CHANGES.md index 41ba5357b..8b88dc423 100644 --- a/actix-protobuf/CHANGES.md +++ b/actix-protobuf/CHANGES.md @@ -1,6 +1,11 @@ # Changes -## Unreleased - 2021-xx-xx +## Unreleased - 2022-xx-xx + + +## 0.8.0 - 2022-06-25 +- Update `prost` dependency to `0.10`. +- Minimum supported Rust version (MSRV) is now 1.57 due to transitive `time` dependency. ## 0.7.0 - 2022-03-01 diff --git a/actix-protobuf/Cargo.toml b/actix-protobuf/Cargo.toml index e8f199a67..439e28592 100644 --- a/actix-protobuf/Cargo.toml +++ b/actix-protobuf/Cargo.toml @@ -1,17 +1,16 @@ [package] name = "actix-protobuf" -version = "0.7.0" +version = "0.8.0" edition = "2018" authors = [ "kingxsp ", - "Yuki Okushi " + "Yuki Okushi ", ] -description = "Protobuf support for Actix web" -keywords = ["actix", "protobuf", "protocol", "rpc"] +description = "Protobuf support for Actix Web" +keywords = ["actix", "web", "protobuf", "protocol", "rpc"] homepage = "https://actix.rs" repository = "https://github.com/actix/actix-extras.git" license = "MIT OR Apache-2.0" -exclude = [".cargo/config", "/examples/**"] [lib] name = "actix_protobuf" @@ -21,8 +20,8 @@ path = "src/lib.rs" actix-web = { version = "4", default_features = false } derive_more = "0.99.5" futures-util = { version = "0.3.7", default-features = false } -prost = { version = "0.9", default_features = false } +prost = { version = "0.10", default_features = false } [dev-dependencies] actix-web = { version = "4", default_features = false, features = ["macros"] } -prost = { version = "0.9", default_features = false, features = ["prost-derive"] } +prost = { version = "0.10", default_features = false, features = ["prost-derive"] } diff --git a/actix-protobuf/README.md b/actix-protobuf/README.md index 6833f0927..9ca0a444f 100644 --- a/actix-protobuf/README.md +++ b/actix-protobuf/README.md @@ -3,15 +3,15 @@ > Protobuf support for Actix Web. [![crates.io](https://img.shields.io/crates/v/actix-protobuf?label=latest)](https://crates.io/crates/actix-protobuf) -[![Documentation](https://docs.rs/actix-protobuf/badge.svg?version=0.7.0)](https://docs.rs/actix-protobuf/0.7.0) +[![Documentation](https://docs.rs/actix-protobuf/badge.svg?version=0.8.0)](https://docs.rs/actix-protobuf/0.8.0) ![Apache 2.0 or MIT licensed](https://img.shields.io/crates/l/actix-protobuf) -[![Dependency Status](https://deps.rs/crate/actix-protobuf/0.7.0/status.svg)](https://deps.rs/crate/actix-protobuf/0.7.0) +[![Dependency Status](https://deps.rs/crate/actix-protobuf/0.8.0/status.svg)](https://deps.rs/crate/actix-protobuf/0.8.0) ## Documentation & Resources - [API Documentation](https://docs.rs/actix-protobuf) - [Example Project](https://github.com/actix/examples/tree/master/protobuf) -- Minimum Supported Rust Version (MSRV): 1.54 +- Minimum Supported Rust Version (MSRV): 1.57 ## Example diff --git a/actix-protobuf/examples/prost-example/Cargo.toml b/actix-protobuf/examples/prost-example/Cargo.toml deleted file mode 100644 index f5baf4bae..000000000 --- a/actix-protobuf/examples/prost-example/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "prost-example" -version = "0.5.1" -edition = "2018" -authors = [ - "kingxsp ", - "Yuki Okushi " -] - -[dependencies] -actix-web = "4" -actix-protobuf = { path = "../../" } - -env_logger = "0.8" -prost = { version = "0.8", default_features = false, features = ["prost-derive"] } diff --git a/actix-protobuf/examples/prost-example/client.py b/actix-protobuf/examples/prost-example/client.py deleted file mode 100755 index 99a93e7d3..000000000 --- a/actix-protobuf/examples/prost-example/client.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -# just start server and run client.py - -# wget https://github.com/protocolbuffers/protobuf/releases/download/v3.11.2/protobuf-python-3.11.2.zip -# unzip protobuf-python-3.11.2.zip -# cd protobuf-3.11.2/python/ -# python3 setup.py install - -# pip3 install --upgrade pip -# pip3 install aiohttp - -# python3 client.py - -import test_pb2 -import traceback -import sys - -import asyncio -import aiohttp - -def op(): - try: - obj = test_pb2.MyObj() - obj.number = 9 - obj.name = 'USB' - - #Serialize - sendDataStr = obj.SerializeToString() - #print serialized string value - print('serialized string:', sendDataStr) - #------------------------# - # message transmission # - #------------------------# - receiveDataStr = sendDataStr - receiveData = test_pb2.MyObj() - - #Deserialize - receiveData.ParseFromString(receiveDataStr) - print('pares serialize string, return: devId = ', receiveData.number, ', name = ', receiveData.name) - except(Exception, e): - print(Exception, ':', e) - print(traceback.print_exc()) - errInfo = sys.exc_info() - print(errInfo[0], ':', errInfo[1]) - - -async def fetch(session): - obj = test_pb2.MyObj() - obj.number = 9 - obj.name = 'USB' - async with session.post('http://127.0.0.1:8081/', data=obj.SerializeToString(), - headers={"content-type": "application/protobuf"}) as resp: - print(resp.status) - data = await resp.read() - receiveObj = test_pb2.MyObj() - receiveObj.ParseFromString(data) - print(receiveObj) - -async def go(loop): - obj = test_pb2.MyObj() - obj.number = 9 - obj.name = 'USB' - async with aiohttp.ClientSession(loop=loop) as session: - await fetch(session) - -loop = asyncio.get_event_loop() -loop.run_until_complete(go(loop)) -loop.close() \ No newline at end of file diff --git a/actix-protobuf/examples/prost-example/src/main.rs b/actix-protobuf/examples/prost-example/src/main.rs deleted file mode 100644 index 8dab194d7..000000000 --- a/actix-protobuf/examples/prost-example/src/main.rs +++ /dev/null @@ -1,33 +0,0 @@ -use actix_protobuf::*; -use actix_web::*; -use prost::Message; - -#[derive(Clone, PartialEq, Message)] -pub struct MyObj { - #[prost(int32, tag = "1")] - pub number: i32, - - #[prost(string, tag = "2")] - pub name: String, -} - -async fn index(msg: ProtoBuf) -> Result { - println!("model: {:?}", msg); - HttpResponse::Ok().protobuf(msg.0) // <- send response -} - -#[actix_web::main] -async fn main() -> std::io::Result<()> { - std::env::set_var("RUST_LOG", "actix_web=debug,actix_server=info"); - env_logger::init(); - - HttpServer::new(|| { - App::new() - .wrap(middleware::Logger::default()) - .service(web::resource("/").route(web::post().to(index))) - }) - .bind("127.0.0.1:8081")? - .shutdown_timeout(1) - .run() - .await -} diff --git a/actix-protobuf/examples/prost-example/test.proto b/actix-protobuf/examples/prost-example/test.proto deleted file mode 100644 index 8ec278ca4..000000000 --- a/actix-protobuf/examples/prost-example/test.proto +++ /dev/null @@ -1,6 +0,0 @@ -syntax = "proto3"; - -message MyObj { - int32 number = 1; - string name = 2; -} \ No newline at end of file diff --git a/actix-protobuf/examples/prost-example/test_pb2.py b/actix-protobuf/examples/prost-example/test_pb2.py deleted file mode 100644 index ea5a1d9d6..000000000 --- a/actix-protobuf/examples/prost-example/test_pb2.py +++ /dev/null @@ -1,75 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by the protocol buffer compiler. DO NOT EDIT! -# source: test.proto - -from google.protobuf import descriptor as _descriptor -from google.protobuf import message as _message -from google.protobuf import reflection as _reflection -from google.protobuf import symbol_database as _symbol_database -# @@protoc_insertion_point(imports) - -_sym_db = _symbol_database.Default() - - - - -DESCRIPTOR = _descriptor.FileDescriptor( - name='test.proto', - package='', - syntax='proto3', - serialized_options=None, - serialized_pb=b'\n\ntest.proto\"%\n\x05MyObj\x12\x0e\n\x06number\x18\x01 \x01(\x05\x12\x0c\n\x04name\x18\x02 \x01(\tb\x06proto3' -) - - - - -_MYOBJ = _descriptor.Descriptor( - name='MyObj', - full_name='MyObj', - filename=None, - file=DESCRIPTOR, - containing_type=None, - fields=[ - _descriptor.FieldDescriptor( - name='number', full_name='MyObj.number', index=0, - number=1, type=5, cpp_type=1, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), - _descriptor.FieldDescriptor( - name='name', full_name='MyObj.name', index=1, - number=2, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=14, - serialized_end=51, -) - -DESCRIPTOR.message_types_by_name['MyObj'] = _MYOBJ -_sym_db.RegisterFileDescriptor(DESCRIPTOR) - -MyObj = _reflection.GeneratedProtocolMessageType('MyObj', (_message.Message,), { - 'DESCRIPTOR' : _MYOBJ, - '__module__' : 'test_pb2' - # @@protoc_insertion_point(class_scope:MyObj) - }) -_sym_db.RegisterMessage(MyObj) - - -# @@protoc_insertion_point(module_scope) diff --git a/actix-redis/CHANGES.md b/actix-redis/CHANGES.md index d73adc1d2..abc691c7a 100644 --- a/actix-redis/CHANGES.md +++ b/actix-redis/CHANGES.md @@ -1,6 +1,13 @@ # Changes -## Unreleased - 2021-xx-xx +## Unreleased - 2022-xx-xx + + +## 0.12.0 - 2022-07-09 +- Update `actix` dependency to `0.13`. +- Update `redis-async` dependency to `0.13`. +- Update `tokio-util` dependency to `0.7`. +- Minimum supported Rust version (MSRV) is now 1.57 due to transitive `time` dependency. ## 0.11.0 - 2022-03-15 diff --git a/actix-redis/Cargo.toml b/actix-redis/Cargo.toml index 4720770e5..3fdfd1518 100644 --- a/actix-redis/Cargo.toml +++ b/actix-redis/Cargo.toml @@ -1,16 +1,19 @@ [package] name = "actix-redis" -version = "0.11.0" +version = "0.12.0" authors = ["Nikolay Kim "] description = "Redis integration for Actix" license = "MIT OR Apache-2.0" -keywords = ["actix", "redis", "async", "session"] +keywords = ["actix", "redis", "async"] homepage = "https://actix.rs" repository = "https://github.com/actix/actix-extras.git" categories = ["network-programming", "asynchronous"] -exclude = [".cargo/config"] edition = "2018" +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + [lib] name = "actix_redis" path = "src/lib.rs" @@ -22,7 +25,7 @@ default = ["web"] web = ["actix-web"] [dependencies] -actix = { version = "0.12", default-features = false } +actix = { version = "0.13", default-features = false } actix-rt = { version = "2.1", default-features = false } actix-service = "2" actix-tls = { version = "3", default-features = false, features = ["connect"] } @@ -31,10 +34,10 @@ log = "0.4.6" backoff = "0.4.0" derive_more = "0.99.5" futures-core = { version = "0.3.7", default-features = false } -redis-async = { version = "0.12", default-features = false, features = ["tokio10"] } +redis-async = "0.13" time = "0.3" tokio = { version = "1.13.1", features = ["sync"] } -tokio-util = "0.6.1" +tokio-util = "0.7" actix-web = { version = "4", default_features = false, optional = true } [dev-dependencies] diff --git a/actix-redis/README.md b/actix-redis/README.md index 26a90b551..c3dcd2234 100644 --- a/actix-redis/README.md +++ b/actix-redis/README.md @@ -3,12 +3,12 @@ > Redis integration for Actix. [![crates.io](https://img.shields.io/crates/v/actix-redis?label=latest)](https://crates.io/crates/actix-redis) -[![Documentation](https://docs.rs/actix-redis/badge.svg?version=0.11.0)](https://docs.rs/actix-redis/0.11.0) +[![Documentation](https://docs.rs/actix-redis/badge.svg?version=0.12.0)](https://docs.rs/actix-redis/0.12.0) ![Apache 2.0 or MIT licensed](https://img.shields.io/crates/l/actix-redis) -[![Dependency Status](https://deps.rs/crate/actix-redis/0.11.0/status.svg)](https://deps.rs/crate/actix-redis/0.11.0) +[![Dependency Status](https://deps.rs/crate/actix-redis/0.12.0/status.svg)](https://deps.rs/crate/actix-redis/0.12.0) ## Documentation & Resources - [API Documentation](https://docs.rs/actix-redis) - [Example Project](https://github.com/actix/examples/tree/master/auth/redis-session) -- Minimum Supported Rust Version (MSRV): 1.54 +- Minimum Supported Rust Version (MSRV): 1.57 diff --git a/actix-redis/src/lib.rs b/actix-redis/src/lib.rs index 26a94a72f..1c183fa81 100644 --- a/actix-redis/src/lib.rs +++ b/actix-redis/src/lib.rs @@ -1,5 +1,6 @@ //! Redis integration for `actix`. +#![forbid(unsafe_code)] #![deny(rust_2018_idioms, nonstandard_style)] #![warn(future_incompatible)] diff --git a/actix-session/CHANGES.md b/actix-session/CHANGES.md index 014b05308..a9b4ddfc6 100644 --- a/actix-session/CHANGES.md +++ b/actix-session/CHANGES.md @@ -6,6 +6,23 @@ - Add an `empty()` function to `Session` for simple testing +## 0.7.0 - 2022-07-09 +- Added `TtlExtensionPolicy` enum to support different strategies for extending the TTL attached to the session state. `TtlExtensionPolicy::OnEveryRequest` now allows for long-lived sessions that do not expire if the user remains active. [#233] +- `SessionLength` is now called `SessionLifecycle`. [#233] +- `SessionLength::Predetermined` is now called `SessionLifecycle::PersistentSession`. [#233] +- The fields for Both `SessionLength` variants have been extracted into separate types (`PersistentSession` and `BrowserSession`). All fields are now private, manipulated via methods, to allow adding more configuration parameters in the future in a non-breaking fashion. [#233] +- `SessionLength::Predetermined::max_session_length` is now called `PersistentSession::session_ttl`. [#233] +- `SessionLength::BrowserSession::state_ttl` is now called `BrowserSession::session_state_ttl`. [#233] +- `SessionMiddlewareBuilder::max_session_length` is now called `SessionMiddlewareBuilder::session_lifecycle`. [#233] +- The `SessionStore` trait requires the implementation of a new method, `SessionStore::update_ttl`. [#233] +- All types used to configure `SessionMiddleware` have been moved to the `config` sub-module. [#233] +- Update `actix` dependency to `0.13`. +- Update `actix-redis` dependency to `0.12`. +- Minimum supported Rust version (MSRV) is now 1.57 due to transitive `time` dependency. + +[#233]: https://github.com/actix/actix-extras/pull/233 + + ## 0.6.2 - 2022-03-25 - Implement `SessionExt` for `GuardContext`. [#234] - `RedisSessionStore` will prevent connection timeouts from causing user-visible errors. [#235] diff --git a/actix-session/Cargo.toml b/actix-session/Cargo.toml index dd9c3e0d2..fdbf9a6d7 100644 --- a/actix-session/Cargo.toml +++ b/actix-session/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "actix-session" -version = "0.6.2" +version = "0.7.0" authors = [ "Nikolay Kim ", "Luca Palmieri ", @@ -38,12 +38,11 @@ derive_more = "0.99.5" rand = { version = "0.8", optional = true } serde = { version = "1" } serde_json = { version = "1" } -time = "0.3" tracing = { version = "0.1.30", default-features = false, features = ["log"] } # redis-actor-session -actix = { version = "0.12.0", default-features = false, optional = true } -actix-redis = { version = "0.11.0", optional = true } +actix = { version = "0.13", default-features = false, optional = true } +actix-redis = { version = "0.12", optional = true } futures-core = { version = "0.3.7", default-features = false, optional = true } # redis-rs-session diff --git a/actix-session/README.md b/actix-session/README.md index 127d45819..c9aceceb9 100644 --- a/actix-session/README.md +++ b/actix-session/README.md @@ -3,13 +3,12 @@ > Session management for Actix Web applications. [![crates.io](https://img.shields.io/crates/v/actix-session?label=latest)](https://crates.io/crates/actix-session) -[![Documentation](https://docs.rs/actix-session/badge.svg?version=0.6.2)](https://docs.rs/actix-session/0.6.2) +[![Documentation](https://docs.rs/actix-session/badge.svg?version=0.7.0)](https://docs.rs/actix-session/0.7.0) ![Apache 2.0 or MIT licensed](https://img.shields.io/crates/l/actix-session) -[![Dependency Status](https://deps.rs/crate/actix-session/0.6.2/status.svg)](https://deps.rs/crate/actix-session/0.6.2) - +[![Dependency Status](https://deps.rs/crate/actix-session/0.7.0/status.svg)](https://deps.rs/crate/actix-session/0.7.0) ## Documentation & Resources - [API Documentation](https://docs.rs/actix-session) - [Example Projects](https://github.com/actix/examples/tree/master/auth/cookie-session) -- Minimum Supported Rust Version (MSRV): 1.54 +- Minimum Supported Rust Version (MSRV): 1.57 diff --git a/actix-session/src/config.rs b/actix-session/src/config.rs new file mode 100644 index 000000000..6cd96764d --- /dev/null +++ b/actix-session/src/config.rs @@ -0,0 +1,369 @@ +//! Configuration options to tune the behaviour of [`SessionMiddleware`]. + +use actix_web::cookie::{time::Duration, Key, SameSite}; + +use crate::{storage::SessionStore, SessionMiddleware}; + +/// Determines what type of session cookie should be used and how its lifecycle should be managed. +/// +/// Used by [`SessionMiddlewareBuilder::session_lifecycle`]. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum SessionLifecycle { + /// The session cookie will expire when the current browser session ends. + /// + /// When does a browser session end? It depends on the browser! Chrome, for example, will often + /// continue running in the background when the browser is closed—session cookies are not + /// deleted and they will still be available when the browser is opened again. + /// Check the documentation of the browsers you are targeting for up-to-date information. + BrowserSession(BrowserSession), + + /// The session cookie will be a [persistent cookie]. + /// + /// Persistent cookies have a pre-determined lifetime, specified via the `Max-Age` or `Expires` + /// attribute. They do not disappear when the current browser session ends. + /// + /// [persistent cookie]: https://www.whitehatsec.com/glossary/content/persistent-session-cookie + PersistentSession(PersistentSession), +} + +impl From for SessionLifecycle { + fn from(session: BrowserSession) -> Self { + Self::BrowserSession(session) + } +} + +impl From for SessionLifecycle { + fn from(session: PersistentSession) -> Self { + Self::PersistentSession(session) + } +} + +/// A [session lifecycle](SessionLifecycle) strategy where the session cookie expires when the +/// browser's current session ends. +/// +/// When does a browser session end? It depends on the browser. Chrome, for example, will often +/// continue running in the background when the browser is closed—session cookies are not deleted +/// and they will still be available when the browser is opened again. Check the documentation of +/// the browsers you are targeting for up-to-date information. +#[derive(Debug, Clone)] +pub struct BrowserSession { + state_ttl: Duration, + state_ttl_extension_policy: TtlExtensionPolicy, +} + +impl BrowserSession { + /// Sets a time-to-live (TTL) when storing the session state in the storage backend. + /// + /// We do not want to store session states indefinitely, otherwise we will inevitably run out of + /// storage by holding on to the state of countless abandoned or expired sessions! + /// + /// We are dealing with the lifecycle of two uncorrelated object here: the session cookie + /// and the session state. It is not a big issue if the session state outlives the cookie— + /// we are wasting some space in the backend storage, but it will be cleaned up eventually. + /// What happens, instead, if the cookie outlives the session state? A new session starts— + /// e.g. if sessions are being used for authentication, the user is de-facto logged out. + /// + /// It is not possible to predict with certainty how long a browser session is going to + /// last—you need to provide a reasonable upper bound. You do so via `state_ttl`—it dictates + /// what TTL should be used for session state when the lifecycle of the session cookie is + /// tied to the browser session length. [`SessionMiddleware`] will default to 1 day if + /// `state_ttl` is left unspecified. + /// + /// You can mitigate the risk of the session cookie outliving the session state by + /// specifying a more aggressive state TTL extension policy - check out + /// [`BrowserSession::state_ttl_extension_policy`] for more details. + pub fn state_ttl(mut self, ttl: Duration) -> Self { + self.state_ttl = ttl; + self + } + + /// Determine under what circumstances the TTL of your session state should be extended. + /// + /// Defaults to [`TtlExtensionPolicy::OnStateChanges`] if left unspecified. + /// + /// See [`TtlExtensionPolicy`] for more details. + pub fn state_ttl_extension_policy(mut self, ttl_extension_policy: TtlExtensionPolicy) -> Self { + self.state_ttl_extension_policy = ttl_extension_policy; + self + } +} + +impl Default for BrowserSession { + fn default() -> Self { + Self { + state_ttl: default_ttl(), + state_ttl_extension_policy: default_ttl_extension_policy(), + } + } +} + +/// A [session lifecycle](SessionLifecycle) strategy where the session cookie will be [persistent]. +/// +/// Persistent cookies have a pre-determined expiration, specified via the `Max-Age` or `Expires` +/// attribute. They do not disappear when the current browser session ends. +/// +/// [persistent]: https://www.whitehatsec.com/glossary/content/persistent-session-cookie +#[derive(Debug, Clone)] +pub struct PersistentSession { + session_ttl: Duration, + ttl_extension_policy: TtlExtensionPolicy, +} + +impl PersistentSession { + /// Specifies how long the session cookie should live. + /// + /// Defaults to 1 day if left unspecified. + /// + /// The session TTL is also used as the TTL for the session state in the storage backend. + /// + /// A persistent session can live more than the specified TTL if the TTL is extended. + /// See [`session_ttl_extension_policy`](Self::session_ttl_extension_policy) for more details. + pub fn session_ttl(mut self, session_ttl: Duration) -> Self { + self.session_ttl = session_ttl; + self + } + + /// Determines under what circumstances the TTL of your session should be extended. + /// See [`TtlExtensionPolicy`] for more details. + /// + /// Defaults to [`TtlExtensionPolicy::OnStateChanges`] if left unspecified. + pub fn session_ttl_extension_policy( + mut self, + ttl_extension_policy: TtlExtensionPolicy, + ) -> Self { + self.ttl_extension_policy = ttl_extension_policy; + self + } +} + +impl Default for PersistentSession { + fn default() -> Self { + Self { + session_ttl: default_ttl(), + ttl_extension_policy: default_ttl_extension_policy(), + } + } +} + +/// Configuration for which events should trigger an extension of the time-to-live for your session. +/// +/// If you are using a [`BrowserSession`], `TtlExtensionPolicy` controls how often the TTL of +/// the session state should be refreshed. The browser is in control of the lifecycle of the +/// session cookie. +/// +/// If you are using a [`PersistentSession`], `TtlExtensionPolicy` controls both the expiration +/// of the session cookie and the TTL of the session state. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum TtlExtensionPolicy { + /// The TTL is refreshed every time the server receives a request associated with a session. + /// + /// # Performance impact + /// Refreshing the TTL on every request is not free. + /// It implies a refresh of the TTL on the session state. This translates into a request over + /// the network if you are using a remote system as storage backend (e.g. Redis). + /// This impacts both the total load on your storage backend (i.e. number of + /// queries it has to handle) and the latency of the requests served by your server. + OnEveryRequest, + + /// The TTL is refreshed every time the session state changes or the session key is renewed. + OnStateChanges, +} + +/// Determines how to secure the content of the session cookie. +/// +/// Used by [`SessionMiddlewareBuilder::cookie_content_security`]. +#[derive(Debug, Clone, Copy)] +pub enum CookieContentSecurity { + /// The cookie content is encrypted when using `CookieContentSecurity::Private`. + /// + /// Encryption guarantees confidentiality and integrity: the client cannot tamper with the + /// cookie content nor decode it, as long as the encryption key remains confidential. + Private, + + /// The cookie content is signed when using `CookieContentSecurity::Signed`. + /// + /// Signing guarantees integrity, but it doesn't ensure confidentiality: the client cannot + /// tamper with the cookie content, but they can read it. + Signed, +} + +pub(crate) const fn default_ttl() -> Duration { + Duration::days(1) +} + +pub(crate) const fn default_ttl_extension_policy() -> TtlExtensionPolicy { + TtlExtensionPolicy::OnStateChanges +} + +/// A fluent builder to construct a [`SessionMiddleware`] instance with custom configuration +/// parameters. +#[must_use] +pub struct SessionMiddlewareBuilder { + storage_backend: Store, + configuration: Configuration, +} + +impl SessionMiddlewareBuilder { + pub(crate) fn new(store: Store, configuration: Configuration) -> Self { + Self { + storage_backend: store, + configuration, + } + } + + /// Set the name of the cookie used to store the session ID. + /// + /// Defaults to `id`. + pub fn cookie_name(mut self, name: String) -> Self { + self.configuration.cookie.name = name; + self + } + + /// Set the `Secure` attribute for the cookie used to store the session ID. + /// + /// If the cookie is set as secure, it will only be transmitted when the connection is secure + /// (using `https`). + /// + /// Default is `true`. + pub fn cookie_secure(mut self, secure: bool) -> Self { + self.configuration.cookie.secure = secure; + self + } + + /// Determines what type of session cookie should be used and how its lifecycle should be managed. + /// Check out [`SessionLifecycle`]'s documentation for more details on the available options. + /// + /// Default is [`SessionLifecycle::BrowserSession`]. + pub fn session_lifecycle>(mut self, session_lifecycle: S) -> Self { + match session_lifecycle.into() { + SessionLifecycle::BrowserSession(BrowserSession { + state_ttl, + state_ttl_extension_policy, + }) => { + self.configuration.cookie.max_age = None; + self.configuration.session.state_ttl = state_ttl; + self.configuration.ttl_extension_policy = state_ttl_extension_policy; + } + SessionLifecycle::PersistentSession(PersistentSession { + session_ttl, + ttl_extension_policy, + }) => { + self.configuration.cookie.max_age = Some(session_ttl); + self.configuration.session.state_ttl = session_ttl; + self.configuration.ttl_extension_policy = ttl_extension_policy; + } + } + + self + } + + /// Set the `SameSite` attribute for the cookie used to store the session ID. + /// + /// By default, the attribute is set to `Lax`. + pub fn cookie_same_site(mut self, same_site: SameSite) -> Self { + self.configuration.cookie.same_site = same_site; + self + } + + /// Set the `Path` attribute for the cookie used to store the session ID. + /// + /// By default, the attribute is set to `/`. + pub fn cookie_path(mut self, path: String) -> Self { + self.configuration.cookie.path = path; + self + } + + /// Set the `Domain` attribute for the cookie used to store the session ID. + /// + /// Use `None` to leave the attribute unspecified. If unspecified, the attribute defaults + /// to the same host that set the cookie, excluding subdomains. + /// + /// By default, the attribute is left unspecified. + pub fn cookie_domain(mut self, domain: Option) -> Self { + self.configuration.cookie.domain = domain; + self + } + + /// Choose how the session cookie content should be secured. + /// + /// - [`CookieContentSecurity::Private`] selects encrypted cookie content. + /// - [`CookieContentSecurity::Signed`] selects signed cookie content. + /// + /// # Default + /// By default, the cookie content is encrypted. Encrypted was chosen instead of signed as + /// default because it reduces the chances of sensitive information being exposed in the session + /// key by accident, regardless of [`SessionStore`] implementation you chose to use. + /// + /// For example, if you are using cookie-based storage, you definitely want the cookie content + /// to be encrypted—the whole session state is embedded in the cookie! If you are using + /// Redis-based storage, signed is more than enough - the cookie content is just a unique + /// tamper-proof session key. + pub fn cookie_content_security(mut self, content_security: CookieContentSecurity) -> Self { + self.configuration.cookie.content_security = content_security; + self + } + + /// Set the `HttpOnly` attribute for the cookie used to store the session ID. + /// + /// If the cookie is set as `HttpOnly`, it will not be visible to any JavaScript snippets + /// running in the browser. + /// + /// Default is `true`. + pub fn cookie_http_only(mut self, http_only: bool) -> Self { + self.configuration.cookie.http_only = http_only; + self + } + + /// Finalise the builder and return a [`SessionMiddleware`] instance. + #[must_use] + pub fn build(self) -> SessionMiddleware { + SessionMiddleware::from_parts(self.storage_backend, self.configuration) + } +} + +#[derive(Clone)] +pub(crate) struct Configuration { + pub(crate) cookie: CookieConfiguration, + pub(crate) session: SessionConfiguration, + pub(crate) ttl_extension_policy: TtlExtensionPolicy, +} + +#[derive(Clone)] +pub(crate) struct SessionConfiguration { + pub(crate) state_ttl: Duration, +} + +#[derive(Clone)] +pub(crate) struct CookieConfiguration { + pub(crate) secure: bool, + pub(crate) http_only: bool, + pub(crate) name: String, + pub(crate) same_site: SameSite, + pub(crate) path: String, + pub(crate) domain: Option, + pub(crate) max_age: Option, + pub(crate) content_security: CookieContentSecurity, + pub(crate) key: Key, +} + +pub(crate) fn default_configuration(key: Key) -> Configuration { + Configuration { + cookie: CookieConfiguration { + secure: true, + http_only: true, + name: "id".into(), + same_site: SameSite::Lax, + path: "/".into(), + domain: None, + max_age: None, + content_security: CookieContentSecurity::Private, + key, + }, + session: SessionConfiguration { + state_ttl: default_ttl(), + }, + ttl_extension_policy: default_ttl_extension_policy(), + } +} diff --git a/actix-session/src/lib.rs b/actix-session/src/lib.rs index 67d5c92a5..db8271281 100644 --- a/actix-session/src/lib.rs +++ b/actix-session/src/lib.rs @@ -133,38 +133,32 @@ //! [`RedisSessionStore`]: storage::RedisSessionStore //! [`RedisActorSessionStore`]: storage::RedisActorSessionStore +#![forbid(unsafe_code)] #![deny(rust_2018_idioms, nonstandard_style)] #![warn(future_incompatible, missing_docs)] #![doc(html_logo_url = "https://actix.rs/img/logo.png")] #![doc(html_favicon_url = "https://actix.rs/favicon.ico")] #![cfg_attr(docsrs, feature(doc_cfg))] +pub mod config; mod middleware; mod session; mod session_ext; pub mod storage; -pub use self::middleware::{ - CookieContentSecurity, SessionLength, SessionMiddleware, SessionMiddlewareBuilder, -}; -pub use self::session::{Session, SessionStatus}; +pub use self::middleware::SessionMiddleware; +pub use self::session::{Session, SessionGetError, SessionInsertError, SessionStatus}; pub use self::session_ext::SessionExt; #[cfg(test)] pub mod test_helpers { use actix_web::cookie::Key; - use rand::{distributions::Alphanumeric, thread_rng, Rng}; - use crate::{storage::SessionStore, CookieContentSecurity}; + use crate::{config::CookieContentSecurity, storage::SessionStore}; /// Generate a random cookie signing/encryption key. pub fn key() -> Key { - let signing_key: String = thread_rng() - .sample_iter(&Alphanumeric) - .take(64) - .map(char::from) - .collect(); - Key::from(signing_key.as_bytes()) + Key::generate() } /// A ready-to-go acceptance test suite to verify that sessions behave as expected @@ -187,6 +181,11 @@ pub mod test_helpers { acceptance_tests::basic_workflow(store_builder.clone(), *policy).await; acceptance_tests::expiration_is_refreshed_on_changes(store_builder.clone(), *policy) .await; + acceptance_tests::expiration_is_always_refreshed_if_configured_to_refresh_on_every_request( + store_builder.clone(), + *policy, + ) + .await; acceptance_tests::complex_workflow( store_builder.clone(), is_invalidation_supported, @@ -199,18 +198,18 @@ pub mod test_helpers { mod acceptance_tests { use actix_web::{ - dev::Service, + cookie::time, + dev::{Service, ServiceResponse}, guard, middleware, test, web::{self, get, post, resource, Bytes}, App, HttpResponse, Result, }; use serde::{Deserialize, Serialize}; use serde_json::json; - use time::Duration; + use crate::config::{CookieContentSecurity, PersistentSession, TtlExtensionPolicy}; use crate::{ - middleware::SessionLength, storage::SessionStore, test_helpers::key, - CookieContentSecurity, Session, SessionExt, SessionMiddleware, + storage::SessionStore, test_helpers::key, Session, SessionExt, SessionMiddleware, }; pub(super) async fn basic_workflow( @@ -228,9 +227,10 @@ pub mod test_helpers { .cookie_name("actix-test".into()) .cookie_domain(Some("localhost".into())) .cookie_content_security(policy) - .session_length(SessionLength::Predetermined { - max_session_length: Some(time::Duration::seconds(100)), - }) + .session_lifecycle( + PersistentSession::default() + .session_ttl(time::Duration::seconds(100)), + ) .build(), ) .service(web::resource("/").to(|ses: Session| async move { @@ -246,12 +246,7 @@ pub mod test_helpers { let request = test::TestRequest::get().to_request(); let response = app.call(request).await.unwrap(); - let cookie = response - .response() - .cookies() - .find(|c| c.name() == "actix-test") - .unwrap() - .clone(); + let cookie = response.get_cookie("actix-test").unwrap().clone(); assert_eq!(cookie.path().unwrap(), "/test/"); let request = test::TestRequest::with_uri("/test/") @@ -261,6 +256,55 @@ pub mod test_helpers { assert_eq!(body, Bytes::from_static(b"counter: 100")); } + pub(super) async fn expiration_is_always_refreshed_if_configured_to_refresh_on_every_request< + F, + Store, + >( + store_builder: F, + policy: CookieContentSecurity, + ) where + Store: SessionStore + 'static, + F: Fn() -> Store + Clone + Send + 'static, + { + let session_ttl = time::Duration::seconds(60); + let app = test::init_service( + App::new() + .wrap( + SessionMiddleware::builder(store_builder(), key()) + .cookie_content_security(policy) + .session_lifecycle( + PersistentSession::default() + .session_ttl(session_ttl) + .session_ttl_extension_policy( + TtlExtensionPolicy::OnEveryRequest, + ), + ) + .build(), + ) + .service(web::resource("/").to(|ses: Session| async move { + let _ = ses.insert("counter", 100); + "test" + })) + .service(web::resource("/test/").to(|| async move { "no-changes-in-session" })), + ) + .await; + + // Create session + let request = test::TestRequest::get().to_request(); + let response = app.call(request).await.unwrap(); + let cookie_1 = response.get_cookie("id").expect("Cookie is set"); + assert_eq!(cookie_1.max_age(), Some(session_ttl)); + + // Fire a request that doesn't touch the session state, check + // that the session cookie is present and its expiry is set to the maximum we configured. + let request = test::TestRequest::with_uri("/test/") + .cookie(cookie_1) + .to_request(); + let response = app.call(request).await.unwrap(); + let cookie_2 = response.get_cookie("id").expect("Cookie is set"); + assert_eq!(cookie_2.max_age(), Some(session_ttl)); + } + pub(super) async fn expiration_is_refreshed_on_changes( store_builder: F, policy: CookieContentSecurity, @@ -268,14 +312,15 @@ pub mod test_helpers { Store: SessionStore + 'static, F: Fn() -> Store + Clone + Send + 'static, { + let session_ttl = time::Duration::seconds(60); let app = test::init_service( App::new() .wrap( SessionMiddleware::builder(store_builder(), key()) .cookie_content_security(policy) - .session_length(SessionLength::Predetermined { - max_session_length: Some(time::Duration::seconds(60)), - }) + .session_lifecycle( + PersistentSession::default().session_ttl(session_ttl), + ) .build(), ) .service(web::resource("/").to(|ses: Session| async move { @@ -288,25 +333,19 @@ pub mod test_helpers { let request = test::TestRequest::get().to_request(); let response = app.call(request).await.unwrap(); - let cookie_1 = response - .response() - .cookies() - .find(|c| c.name() == "id") - .expect("Cookie is set"); - assert_eq!(cookie_1.max_age(), Some(Duration::seconds(60))); + let cookie_1 = response.get_cookie("id").expect("Cookie is set"); + assert_eq!(cookie_1.max_age(), Some(session_ttl)); - let request = test::TestRequest::with_uri("/test/").to_request(); + let request = test::TestRequest::with_uri("/test/") + .cookie(cookie_1.clone()) + .to_request(); let response = app.call(request).await.unwrap(); assert!(response.response().cookies().next().is_none()); - let request = test::TestRequest::get().to_request(); + let request = test::TestRequest::get().cookie(cookie_1).to_request(); let response = app.call(request).await.unwrap(); - let cookie_2 = response - .response() - .cookies() - .find(|c| c.name() == "id") - .expect("Cookie is set"); - assert_eq!(cookie_2.max_age(), Some(Duration::seconds(60))); + let cookie_2 = response.get_cookie("id").expect("Cookie is set"); + assert_eq!(cookie_2.max_age(), Some(session_ttl)); } pub(super) async fn guard(store_builder: F, policy: CookieContentSecurity) @@ -320,9 +359,9 @@ pub mod test_helpers { SessionMiddleware::builder(store_builder(), key()) .cookie_name("test-session".into()) .cookie_content_security(policy) - .session_length(SessionLength::Predetermined { - max_session_length: Some(time::Duration::days(7)), - }) + .session_lifecycle( + PersistentSession::default().session_ttl(time::Duration::days(7)), + ) .build(), ) .wrap(middleware::Logger::default()) @@ -402,15 +441,16 @@ pub mod test_helpers { Store: SessionStore + 'static, F: Fn() -> Store + Clone + Send + 'static, { + let session_ttl = time::Duration::days(7); let srv = actix_test::start(move || { App::new() .wrap( SessionMiddleware::builder(store_builder(), key()) .cookie_name("test-session".into()) .cookie_content_security(policy) - .session_length(SessionLength::Predetermined { - max_session_length: Some(time::Duration::days(7)), - }) + .session_lifecycle( + PersistentSession::default().session_ttl(session_ttl), + ) .build(), ) .wrap(middleware::Logger::default()) @@ -456,7 +496,7 @@ pub mod test_helpers { .into_iter() .find(|c| c.name() == "test-session") .unwrap(); - assert_eq!(cookie_1.max_age(), Some(Duration::days(7))); + assert_eq!(cookie_1.max_age(), Some(session_ttl)); // Step 3: GET index, including session cookie #1 in request // - set-cookie will *not* be in response @@ -494,7 +534,7 @@ pub mod test_helpers { .into_iter() .find(|c| c.name() == "test-session") .unwrap(); - assert_eq!(cookie_2.max_age(), Some(Duration::days(7))); + assert_eq!(cookie_2.max_age(), cookie_1.max_age()); // Step 5: POST to login, including session cookie #2 in request // - set-cookie actix-session will be in response (session cookie #3) @@ -675,5 +715,18 @@ pub mod test_helpers { Ok(HttpResponse::Ok().body(body)) } + + trait ServiceResponseExt { + fn get_cookie(&self, cookie_name: &str) -> Option>; + } + + impl ServiceResponseExt for ServiceResponse { + fn get_cookie(&self, cookie_name: &str) -> Option> { + self.response() + .cookies() + .into_iter() + .find(|c| c.name() == cookie_name) + } + } } } diff --git a/actix-session/src/middleware.rs b/actix-session/src/middleware.rs index ce27cf4b5..09389ec26 100644 --- a/actix-session/src/middleware.rs +++ b/actix-session/src/middleware.rs @@ -3,15 +3,18 @@ use std::{collections::HashMap, convert::TryInto, fmt, future::Future, pin::Pin, use actix_utils::future::{ready, Ready}; use actix_web::{ body::MessageBody, - cookie::{Cookie, CookieJar, Key, SameSite}, + cookie::{Cookie, CookieJar, Key}, dev::{forward_ready, ResponseHead, Service, ServiceRequest, ServiceResponse, Transform}, http::header::{HeaderValue, SET_COOKIE}, HttpResponse, }; use anyhow::Context; -use time::Duration; use crate::{ + config::{ + self, Configuration, CookieConfiguration, CookieContentSecurity, SessionMiddlewareBuilder, + TtlExtensionPolicy, + }, storage::{LoadError, SessionKey, SessionStore}, Session, SessionStatus, }; @@ -66,8 +69,9 @@ use crate::{ /// If you want to customise use [`builder`](Self::builder) instead of [`new`](Self::new): /// /// ```no_run -/// use actix_web::{cookie::Key, web, App, HttpServer, HttpResponse, Error}; -/// use actix_session::{Session, SessionMiddleware, storage::RedisActorSessionStore, SessionLength}; +/// use actix_web::{App, cookie::{Key, time}, Error, HttpResponse, HttpServer, web}; +/// use actix_session::{Session, SessionMiddleware, storage::RedisActorSessionStore}; +/// use actix_session::config::PersistentSession; /// /// // The secret key would usually be read from a configuration file/environment variables. /// fn get_secret_key() -> Key { @@ -87,9 +91,10 @@ use crate::{ /// RedisActorSessionStore::new(redis_connection_string), /// secret_key.clone() /// ) -/// .session_length(SessionLength::Predetermined { -/// max_session_length: Some(time::Duration::days(5)), -/// }) +/// .session_lifecycle( +/// PersistentSession::default() +/// .session_ttl(time::Duration::days(5)) +/// ) /// .build(), /// ) /// .default_service(web::to(|| HttpResponse::Ok()))) @@ -114,117 +119,6 @@ pub struct SessionMiddleware { configuration: Rc, } -#[derive(Clone)] -struct Configuration { - cookie: CookieConfiguration, - session: SessionConfiguration, -} - -#[derive(Clone)] -struct SessionConfiguration { - state_ttl: Duration, -} - -#[derive(Clone)] -struct CookieConfiguration { - secure: bool, - http_only: bool, - name: String, - same_site: SameSite, - path: String, - domain: Option, - max_age: Option, - content_security: CookieContentSecurity, - key: Key, -} - -/// Describes how long a session should last. -/// -/// Used by [`SessionMiddlewareBuilder::session_length`]. -#[derive(Clone, Debug)] -pub enum SessionLength { - /// The session cookie will expire when the current browser session ends. - /// - /// When does a browser session end? It depends on the browser! Chrome, for example, will often - /// continue running in the background when the browser is closed—session cookies are not - /// deleted and they will still be available when the browser is opened again. Check the - /// documentation of the browsers you are targeting for up-to-date information. - BrowserSession { - /// We must provide a time-to-live (TTL) when storing the session state in the storage - /// backend—we do not want to store session states indefinitely, otherwise we will - /// inevitably run out of storage by holding on to the state of countless abandoned or - /// expired sessions! - /// - /// We are dealing with the lifecycle of two uncorrelated object here: the session cookie - /// and the session state. It is not a big issue if the session state outlives the cookie— - /// we are wasting some space in the backend storage, but it will be cleaned up eventually. - /// What happens, instead, if the cookie outlives the session state? A new session starts— - /// e.g. if sessions are being used for authentication, the user is de-facto logged out. - /// - /// It is not possible to predict with certainty how long a browser session is going to - /// last—you need to provide a reasonable upper bound. You do so via `state_ttl`—it dictates - /// what TTL should be used for session state when the lifecycle of the session cookie is - /// tied to the browser session length. [`SessionMiddleware`] will default to 1 day if - /// `state_ttl` is left unspecified. - state_ttl: Option, - }, - - /// The session cookie will be a [persistent cookie]. - /// - /// Persistent cookies have a pre-determined lifetime, specified via the `Max-Age` or `Expires` - /// attribute. They do not disappear when the current browser session ends. - /// - /// [persistent cookie]: https://www.whitehatsec.com/glossary/content/persistent-session-cookie - Predetermined { - /// Set `max_session_length` to specify how long the session cookie should live. - /// [`SessionMiddleware`] will default to 1 day if `max_session_length` is set to `None`. - /// - /// `max_session_length` is also used as the TTL for the session state in the - /// storage backend. - max_session_length: Option, - }, -} - -/// Used by [`SessionMiddlewareBuilder::cookie_content_security`] to determine how to secure -/// the content of the session cookie. -#[derive(Debug, Clone, Copy)] -pub enum CookieContentSecurity { - /// The cookie content is encrypted when using `CookieContentSecurity::Private`. - /// - /// Encryption guarantees confidentiality and integrity: the client cannot tamper with the - /// cookie content nor decode it, as long as the encryption key remains confidential. - Private, - - /// The cookie content is signed when using `CookieContentSecurity::Signed`. - /// - /// Signing guarantees integrity, but it doesn't ensure confidentiality: the client cannot - /// tamper with the cookie content, but they can read it. - Signed, -} - -fn default_configuration(key: Key) -> Configuration { - Configuration { - cookie: CookieConfiguration { - secure: true, - http_only: true, - name: "id".into(), - same_site: SameSite::Lax, - path: "/".into(), - domain: None, - max_age: None, - content_security: CookieContentSecurity::Private, - key, - }, - session: SessionConfiguration { - state_ttl: default_ttl(), - }, - } -} - -fn default_ttl() -> Duration { - Duration::days(1) -} - impl SessionMiddleware { /// Use [`SessionMiddleware::new`] to initialize the session framework using the default /// parameters. @@ -234,10 +128,7 @@ impl SessionMiddleware { /// [`SessionStore]); /// - a secret key, to sign or encrypt the content of client-side session cookie. pub fn new(store: Store, key: Key) -> Self { - Self { - storage_backend: Rc::new(store), - configuration: Rc::new(default_configuration(key)), - } + Self::builder(store, key).build() } /// A fluent API to configure [`SessionMiddleware`]. @@ -247,124 +138,13 @@ impl SessionMiddleware { /// [`SessionStore]); /// - a secret key, to sign or encrypt the content of client-side session cookie. pub fn builder(store: Store, key: Key) -> SessionMiddlewareBuilder { - SessionMiddlewareBuilder { + SessionMiddlewareBuilder::new(store, config::default_configuration(key)) + } + + pub(crate) fn from_parts(store: Store, configuration: Configuration) -> Self { + Self { storage_backend: Rc::new(store), - configuration: default_configuration(key), - } - } -} - -/// A fluent builder to construct a [`SessionMiddleware`] instance with custom configuration -/// parameters. -#[must_use] -pub struct SessionMiddlewareBuilder { - storage_backend: Rc, - configuration: Configuration, -} - -impl SessionMiddlewareBuilder { - /// Set the name of the cookie used to store the session ID. - /// - /// Defaults to `id`. - pub fn cookie_name(mut self, name: String) -> Self { - self.configuration.cookie.name = name; - self - } - - /// Set the `Secure` attribute for the cookie used to store the session ID. - /// - /// If the cookie is set as secure, it will only be transmitted when the connection is secure - /// (using `https`). - /// - /// Default is `true`. - pub fn cookie_secure(mut self, secure: bool) -> Self { - self.configuration.cookie.secure = secure; - self - } - - /// Determine how long a session should last - check out [`SessionLength`]'s documentation for - /// more details on the available options. - /// - /// Default is [`SessionLength::BrowserSession`]. - pub fn session_length(mut self, session_length: SessionLength) -> Self { - match session_length { - SessionLength::BrowserSession { state_ttl } => { - self.configuration.cookie.max_age = None; - self.configuration.session.state_ttl = state_ttl.unwrap_or_else(default_ttl); - } - SessionLength::Predetermined { max_session_length } => { - let ttl = max_session_length.unwrap_or_else(default_ttl); - self.configuration.cookie.max_age = Some(ttl); - self.configuration.session.state_ttl = ttl; - } - } - - self - } - - /// Set the `SameSite` attribute for the cookie used to store the session ID. - /// - /// By default, the attribute is set to `Lax`. - pub fn cookie_same_site(mut self, same_site: SameSite) -> Self { - self.configuration.cookie.same_site = same_site; - self - } - - /// Set the `Path` attribute for the cookie used to store the session ID. - /// - /// By default, the attribute is set to `/`. - pub fn cookie_path(mut self, path: String) -> Self { - self.configuration.cookie.path = path; - self - } - - /// Set the `Domain` attribute for the cookie used to store the session ID. - /// - /// Use `None` to leave the attribute unspecified. If unspecified, the attribute defaults - /// to the same host that set the cookie, excluding subdomains. - /// - /// By default, the attribute is left unspecified. - pub fn cookie_domain(mut self, domain: Option) -> Self { - self.configuration.cookie.domain = domain; - self - } - - /// Choose how the session cookie content should be secured. - /// - /// - [`CookieContentSecurity::Private`] selects encrypted cookie content. - /// - [`CookieContentSecurity::Signed`] selects signed cookie content. - /// - /// # Default - /// By default, the cookie content is encrypted. Encrypted was chosen instead of signed as - /// default because it reduces the chances of sensitive information being exposed in the session - /// key by accident, regardless of [`SessionStore`] implementation you chose to use. - /// - /// For example, if you are using cookie-based storage, you definitely want the cookie content - /// to be encrypted—the whole session state is embedded in the cookie! If you are using - /// Redis-based storage, signed is more than enough - the cookie content is just a unique - /// tamper-proof session key. - pub fn cookie_content_security(mut self, content_security: CookieContentSecurity) -> Self { - self.configuration.cookie.content_security = content_security; - self - } - - /// Set the `HttpOnly` attribute for the cookie used to store the session ID. - /// - /// If the cookie is set as `HttpOnly`, it will not be visible to any JavaScript snippets - /// running in the browser. - /// - /// Default is `true`. - pub fn cookie_http_only(mut self, http_only: bool) -> Self { - self.configuration.cookie.http_only = http_only; - self - } - - /// Finalise the builder and return a [`SessionMiddleware`] instance. - #[must_use] - pub fn build(self) -> SessionMiddleware { - SessionMiddleware { - storage_backend: self.storage_backend, - configuration: Rc::new(self.configuration), + configuration: Rc::new(configuration), } } } @@ -509,16 +289,39 @@ where } SessionStatus::Unchanged => { - // Nothing to do; we avoid the unnecessary call to the storage. + if matches!( + configuration.ttl_extension_policy, + TtlExtensionPolicy::OnEveryRequest + ) { + storage_backend + .update_ttl(&session_key, &configuration.session.state_ttl) + .await + .map_err(e500)?; + + if configuration.cookie.max_age.is_some() { + set_session_cookie( + res.response_mut().head_mut(), + session_key, + &configuration.cookie, + ) + .map_err(e500)?; + } + } } - } + }; } - }; + } + Ok(res) }) } } +/// Examines the session cookie attached to the incoming request, if there is one, and tries +/// to extract the session key. +/// +/// It returns `None` if there is no session cookie or if the session cookie is considered invalid +/// (e.g., when failing a signature check). fn extract_session_key(req: &ServiceRequest, config: &CookieConfiguration) -> Option { let cookies = req.cookies().ok()?; let session_cookie = cookies diff --git a/actix-session/src/session.rs b/actix-session/src/session.rs index 5fc4e0a31..d2f263b01 100644 --- a/actix-session/src/session.rs +++ b/actix-session/src/session.rs @@ -1,16 +1,20 @@ use std::{ cell::{Ref, RefCell}, collections::HashMap, + error::Error as StdError, mem, rc::Rc, }; use actix_utils::future::{ready, Ready}; use actix_web::{ + body::BoxBody, dev::{Extensions, Payload, ServiceRequest, ServiceResponse}, error::Error, - FromRequest, HttpMessage, HttpRequest, + FromRequest, HttpMessage, HttpRequest, HttpResponse, ResponseError, }; +use anyhow::Context; +use derive_more::{Display, From}; use serde::{de::DeserializeOwned, Serialize}; /// The primary interface to access and modify session state. @@ -38,6 +42,7 @@ use serde::{de::DeserializeOwned, Serialize}; /// [`SessionExt`]. /// /// [`SessionExt`]: crate::SessionExt +#[derive(Clone)] pub struct Session(Rc>); /// Status of a [`Session`]. @@ -89,9 +94,20 @@ impl Session { /// Get a `value` from the session. /// /// It returns an error if it fails to deserialize as `T` the JSON value associated with `key`. - pub fn get(&self, key: &str) -> Result, serde_json::Error> { + pub fn get(&self, key: &str) -> Result, SessionGetError> { if let Some(val_str) = self.0.borrow().state.get(key) { - Ok(Some(serde_json::from_str(val_str)?)) + Ok(Some( + serde_json::from_str(val_str) + .with_context(|| { + format!( + "Failed to deserialize the JSON-encoded session data attached to key \ + `{}` as a `{}` type", + key, + std::any::type_name::() + ) + }) + .map_err(SessionGetError)?, + )) } else { Ok(None) } @@ -115,17 +131,29 @@ impl Session { /// only a reference to the value is taken. /// /// It returns an error if it fails to serialize `value` to JSON. - pub fn insert( + pub fn insert( &self, key: impl Into, - value: impl Serialize, - ) -> Result<(), serde_json::Error> { + value: T, + ) -> Result<(), SessionInsertError> { let mut inner = self.0.borrow_mut(); if inner.status != SessionStatus::Purged { inner.status = SessionStatus::Changed; - let val = serde_json::to_string(&value)?; - inner.state.insert(key.into(), val); + + let key = key.into(); + let val = serde_json::to_string(&value) + .with_context(|| { + format!( + "Failed to serialize the provided `{}` type instance as JSON in order to \ + attach as session data to the `{}` key", + std::any::type_name::(), + &key + ) + }) + .map_err(SessionInsertError)?; + + inner.state.insert(key, val); } Ok(()) @@ -147,7 +175,7 @@ impl Session { /// Remove value from the session and deserialize. /// - /// Returns None if key was not present in session. Returns `T` if deserialization succeeds, + /// Returns `None` if key was not present in session. Returns `T` if deserialization succeeds, /// otherwise returns un-deserialized JSON string. pub fn remove_as(&self, key: &str) -> Option> { self.remove(key) @@ -155,7 +183,7 @@ impl Session { Ok(val) => Ok(val), Err(_err) => { tracing::debug!( - "removed value (key: {}) could not be deserialized as {}", + "Removed value (key: {}) could not be deserialized as {}", key, std::any::type_name::() ); @@ -206,9 +234,9 @@ impl Session { /// Returns session status and iterator of key-value pairs of changes. /// - /// This is a destructive operation - the session state is removed from the request extensions typemap, - /// leaving behind a new empty map. It should only be used when the session is being finalised (i.e. - /// in `SessionMiddleware`). + /// This is a destructive operation - the session state is removed from the request extensions + /// typemap, leaving behind a new empty map. It should only be used when the session is being + /// finalised (i.e. in `SessionMiddleware`). pub(crate) fn get_changes( res: &mut ServiceResponse, ) -> (SessionStatus, HashMap) { @@ -265,3 +293,37 @@ impl FromRequest for Session { ready(Ok(Session::get_session(&mut *req.extensions_mut()))) } } + +/// Error returned by [`Session::get`]. +#[derive(Debug, Display, From)] +#[display(fmt = "{}", _0)] +pub struct SessionGetError(anyhow::Error); + +impl StdError for SessionGetError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(self.0.as_ref()) + } +} + +impl ResponseError for SessionGetError { + fn error_response(&self) -> HttpResponse { + HttpResponse::new(self.status_code()) + } +} + +/// Error returned by [`Session::insert`]. +#[derive(Debug, Display, From)] +#[display(fmt = "{}", _0)] +pub struct SessionInsertError(anyhow::Error); + +impl StdError for SessionInsertError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(self.0.as_ref()) + } +} + +impl ResponseError for SessionInsertError { + fn error_response(&self) -> HttpResponse { + HttpResponse::new(self.status_code()) + } +} diff --git a/actix-session/src/storage/cookie.rs b/actix-session/src/storage/cookie.rs index 34bdceae4..10cc05bc6 100644 --- a/actix-session/src/storage/cookie.rs +++ b/actix-session/src/storage/cookie.rs @@ -1,6 +1,7 @@ use std::convert::TryInto; -use time::Duration; +use actix_web::cookie::time::Duration; +use anyhow::Error; use super::SessionKey; use crate::storage::{ @@ -34,9 +35,9 @@ use crate::storage::{ /// ``` /// /// # Limitations -/// Cookies are subject to size limits - we require session keys to be shorter than 4096 bytes. This -/// translates into a limit on the maximum size of the session state when using cookies as storage -/// backend. +/// Cookies are subject to size limits so we require session keys to be shorter than 4096 bytes. +/// This translates into a limit on the maximum size of the session state when using cookies as +/// storage backend. /// /// The session cookie can always be inspected by end users via the developer tools exposed by their /// browsers. We strongly recommend setting the policy to [`CookieContentSecurity::Private`] when @@ -45,7 +46,7 @@ use crate::storage::{ /// There is no way to invalidate a session before its natural expiry when using cookies as the /// storage backend. /// -/// [`CookieContentSecurity::Private`]: crate::CookieContentSecurity::Private +/// [`CookieContentSecurity::Private`]: crate::config::CookieContentSecurity::Private #[cfg_attr(docsrs, doc(cfg(feature = "cookie-session")))] #[derive(Default)] #[non_exhaustive] @@ -89,6 +90,10 @@ impl SessionStore for CookieSessionStore { }) } + async fn update_ttl(&self, _session_key: &SessionKey, _ttl: &Duration) -> Result<(), Error> { + Ok(()) + } + async fn delete(&self, _session_key: &SessionKey) -> Result<(), anyhow::Error> { Ok(()) } diff --git a/actix-session/src/storage/interface.rs b/actix-session/src/storage/interface.rs index 64b10338f..2b52c59ed 100644 --- a/actix-session/src/storage/interface.rs +++ b/actix-session/src/storage/interface.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; +use actix_web::cookie::time::Duration; use derive_more::Display; -use time::Duration; use super::SessionKey; @@ -36,6 +36,13 @@ pub trait SessionStore { ttl: &Duration, ) -> Result; + /// Updates the TTL of the session state associated to a pre-existing session key. + async fn update_ttl( + &self, + session_key: &SessionKey, + ttl: &Duration, + ) -> Result<(), anyhow::Error>; + /// Deletes a session from the store. async fn delete(&self, session_key: &SessionKey) -> Result<(), anyhow::Error>; } diff --git a/actix-session/src/storage/redis_actor.rs b/actix-session/src/storage/redis_actor.rs index f226dec34..744f01156 100644 --- a/actix-session/src/storage/redis_actor.rs +++ b/actix-session/src/storage/redis_actor.rs @@ -1,6 +1,7 @@ use actix::Addr; use actix_redis::{resp_array, Command, RedisActor, RespValue}; -use time::{self, Duration}; +use actix_web::cookie::time::Duration; +use anyhow::Error; use super::SessionKey; use crate::storage::{ @@ -238,6 +239,24 @@ impl SessionStore for RedisActorSessionStore { } } + async fn update_ttl(&self, session_key: &SessionKey, ttl: &Duration) -> Result<(), Error> { + let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); + + let cmd = Command(resp_array![ + "EXPIRE", + cache_key, + ttl.whole_seconds().to_string() + ]); + + match self.addr.send(cmd).await? { + Ok(RespValue::Integer(_)) => Ok(()), + val => Err(anyhow::anyhow!( + "Failed to update the session state TTL: {:?}", + val + )), + } + } + async fn delete(&self, session_key: &SessionKey) -> Result<(), anyhow::Error> { let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); @@ -258,9 +277,11 @@ impl SessionStore for RedisActorSessionStore { } #[cfg(test)] -mod test { +mod tests { use std::collections::HashMap; + use actix_web::cookie::time::Duration; + use super::*; use crate::test_helpers::acceptance_test_suite; @@ -286,7 +307,7 @@ mod test { let session_key = generate_session_key(); let initial_session_key = session_key.as_ref().to_owned(); let updated_session_key = store - .update(session_key, HashMap::new(), &time::Duration::seconds(1)) + .update(session_key, HashMap::new(), &Duration::seconds(1)) .await .unwrap(); assert_ne!(initial_session_key, updated_session_key.as_ref()); diff --git a/actix-session/src/storage/redis_rs.rs b/actix-session/src/storage/redis_rs.rs index cba2b356b..04a780279 100644 --- a/actix-session/src/storage/redis_rs.rs +++ b/actix-session/src/storage/redis_rs.rs @@ -1,7 +1,8 @@ -use std::sync::Arc; +use std::{convert::TryInto, sync::Arc}; -use redis::{aio::ConnectionManager, Cmd, FromRedisValue, RedisResult, Value}; -use time::{self, Duration}; +use actix_web::cookie::time::Duration; +use anyhow::{Context, Error}; +use redis::{aio::ConnectionManager, AsyncCommands, Cmd, FromRedisValue, RedisResult, Value}; use super::SessionKey; use crate::storage::{ @@ -28,6 +29,7 @@ use crate::storage::{ /// let secret_key = get_secret_key(); /// let redis_connection_string = "redis://127.0.0.1:6379"; /// let store = RedisSessionStore::new(redis_connection_string).await.unwrap(); +/// /// HttpServer::new(move || /// App::new() /// .wrap(SessionMiddleware::new( @@ -221,6 +223,21 @@ impl SessionStore for RedisSessionStore { } } + async fn update_ttl(&self, session_key: &SessionKey, ttl: &Duration) -> Result<(), Error> { + let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); + + self.client + .clone() + .expire( + &cache_key, + ttl.whole_seconds().try_into().context( + "Failed to convert the state TTL into the number of whole seconds remaining", + )?, + ) + .await?; + Ok(()) + } + async fn delete(&self, session_key: &SessionKey) -> Result<(), anyhow::Error> { let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); self.execute_command(redis::cmd("DEL").arg(&[&cache_key])) @@ -272,9 +289,10 @@ impl RedisSessionStore { } #[cfg(test)] -mod test { +mod tests { use std::collections::HashMap; + use actix_web::cookie::time; use redis::AsyncCommands; use super::*; diff --git a/actix-session/src/storage/session_key.rs b/actix-session/src/storage/session_key.rs index d82e18284..ad5c47a1d 100644 --- a/actix-session/src/storage/session_key.rs +++ b/actix-session/src/storage/session_key.rs @@ -2,16 +2,15 @@ use std::convert::TryFrom; use derive_more::{Display, From}; -/// A session key, the string stored in a client-side cookie to associate a user -/// with its session state on the backend. +/// A session key, the string stored in a client-side cookie to associate a user with its session +/// state on the backend. /// -/// ## Validation -/// -/// Session keys are stored as cookies, therefore they cannot be arbitrary long. -/// We require session keys to be smaller than 4064 bytes. +/// # Validation +/// Session keys are stored as cookies, therefore they cannot be arbitrary long. Session keys are +/// required to be smaller than 4064 bytes. /// /// ```rust -/// use std::convert::TryInto; +/// # use std::convert::TryInto; /// use actix_session::storage::SessionKey; /// /// let key: String = std::iter::repeat('a').take(4065).collect(); @@ -24,15 +23,15 @@ pub struct SessionKey(String); impl TryFrom for SessionKey { type Error = InvalidSessionKeyError; - fn try_from(v: String) -> Result { - if v.len() > 4064 { + fn try_from(val: String) -> Result { + if val.len() > 4064 { return Err(anyhow::anyhow!( "The session key is bigger than 4064 bytes, the upper limit on cookie content." ) .into()); } - Ok(SessionKey(v)) + Ok(SessionKey(val)) } } @@ -43,8 +42,8 @@ impl AsRef for SessionKey { } impl From for String { - fn from(k: SessionKey) -> Self { - k.0 + fn from(key: SessionKey) -> Self { + key.0 } } diff --git a/actix-session/tests/opaque_errors.rs b/actix-session/tests/opaque_errors.rs index 90e0f5c95..378a34752 100644 --- a/actix-session/tests/opaque_errors.rs +++ b/actix-session/tests/opaque_errors.rs @@ -71,6 +71,10 @@ impl SessionStore for MockStore { todo!() } + async fn update_ttl(&self, _session_key: &SessionKey, _ttl: &Duration) -> Result<(), Error> { + todo!() + } + async fn delete(&self, _session_key: &SessionKey) -> Result<(), Error> { todo!() } diff --git a/actix-web-httpauth/CHANGES.md b/actix-web-httpauth/CHANGES.md index e4c280f26..1b72cee94 100644 --- a/actix-web-httpauth/CHANGES.md +++ b/actix-web-httpauth/CHANGES.md @@ -1,6 +1,13 @@ # Changes -## Unreleased - 2021-xx-xx +## Unreleased - 2022-xx-xx + + +## 0.7.0 - 2022-07-19 +- Auth validator functions now need to return `(Error, ServiceRequest)` in error cases. [#260] +- Minimum supported Rust version (MSRV) is now 1.57 due to transitive `time` dependency. + +[#260]: https://github.com/actix/actix-extras/pull/260 ## 0.6.0 - 2022-03-01 diff --git a/actix-web-httpauth/Cargo.toml b/actix-web-httpauth/Cargo.toml index cebb395bf..98bc14721 100644 --- a/actix-web-httpauth/Cargo.toml +++ b/actix-web-httpauth/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "actix-web-httpauth" -version = "0.6.0" +version = "0.7.0" authors = [ "svartalf ", "Yuki Okushi ", diff --git a/actix-web-httpauth/README.md b/actix-web-httpauth/README.md index 63c977e18..1d05158b8 100644 --- a/actix-web-httpauth/README.md +++ b/actix-web-httpauth/README.md @@ -3,14 +3,14 @@ > HTTP authentication schemes for [actix-web](https://github.com/actix/actix-web). [![crates.io](https://img.shields.io/crates/v/actix-web-httpauth?label=latest)](https://crates.io/crates/actix-web-httpauth) -[![Documentation](https://docs.rs/actix-web-httpauth/badge.svg?version=0.6.0)](https://docs.rs/actix-web-httpauth/0.6.0) +[![Documentation](https://docs.rs/actix-web-httpauth/badge.svg?version=0.7.0)](https://docs.rs/actix-web-httpauth/0.7.0) ![Apache 2.0 or MIT licensed](https://img.shields.io/crates/l/actix-web-httpauth) -[![Dependency Status](https://deps.rs/crate/actix-web-httpauth/0.6.0/status.svg)](https://deps.rs/crate/actix-web-httpauth/0.6.0) +[![Dependency Status](https://deps.rs/crate/actix-web-httpauth/0.7.0/status.svg)](https://deps.rs/crate/actix-web-httpauth/0.7.0) ## Documentation & Resources - [API Documentation](https://docs.rs/actix-web-httpauth/) -- Minimum Supported Rust Version (MSRV): 1.54 +- Minimum Supported Rust Version (MSRV): 1.57 ## Features - Typed [Authorization] and [WWW-Authenticate] headers diff --git a/actix-web-httpauth/examples/middleware.rs b/actix-web-httpauth/examples/middleware.rs index 4df4c0341..487f96346 100644 --- a/actix-web-httpauth/examples/middleware.rs +++ b/actix-web-httpauth/examples/middleware.rs @@ -3,7 +3,10 @@ use actix_web::{middleware, web, App, Error, HttpServer}; use actix_web_httpauth::extractors::basic::BasicAuth; use actix_web_httpauth::middleware::HttpAuthentication; -async fn validator(req: ServiceRequest, _credentials: BasicAuth) -> Result { +async fn validator( + req: ServiceRequest, + _credentials: BasicAuth, +) -> Result { Ok(req) } diff --git a/actix-web-httpauth/examples/with-cors.rs b/actix-web-httpauth/examples/with-cors.rs index eb2f890ca..4f034a295 100644 --- a/actix-web-httpauth/examples/with-cors.rs +++ b/actix-web-httpauth/examples/with-cors.rs @@ -5,7 +5,7 @@ use actix_web_httpauth::{extractors::bearer::BearerAuth, middleware::HttpAuthent async fn ok_validator( req: ServiceRequest, credentials: BearerAuth, -) -> Result { +) -> Result { eprintln!("{:?}", credentials); Ok(req) } diff --git a/actix-web-httpauth/src/middleware.rs b/actix-web-httpauth/src/middleware.rs index 16b0004f4..68a41ea8b 100644 --- a/actix-web-httpauth/src/middleware.rs +++ b/actix-web-httpauth/src/middleware.rs @@ -39,7 +39,7 @@ impl HttpAuthentication where T: AuthExtractor, F: Fn(ServiceRequest, T) -> O, - O: Future>, + O: Future>, { /// Construct `HttpAuthentication` middleware with the provided auth extractor `T` and /// validation callback `F`. @@ -54,7 +54,7 @@ where impl HttpAuthentication where F: Fn(ServiceRequest, basic::BasicAuth) -> O, - O: Future>, + O: Future>, { /// Construct `HttpAuthentication` middleware for the HTTP "Basic" authentication scheme. /// @@ -70,7 +70,7 @@ where /// async fn validator( /// req: ServiceRequest, /// credentials: BasicAuth, - /// ) -> Result { + /// ) -> Result { /// // All users are great and more than welcome! /// Ok(req) /// } @@ -85,7 +85,7 @@ where impl HttpAuthentication where F: Fn(ServiceRequest, bearer::BearerAuth) -> O, - O: Future>, + O: Future>, { /// Construct `HttpAuthentication` middleware for the HTTP "Bearer" authentication scheme. /// @@ -96,7 +96,7 @@ where /// # use actix_web_httpauth::middleware::HttpAuthentication; /// # use actix_web_httpauth::extractors::bearer::{Config, BearerAuth}; /// # use actix_web_httpauth::extractors::{AuthenticationError, AuthExtractorConfig}; - /// async fn validator(req: ServiceRequest, credentials: BearerAuth) -> Result { + /// async fn validator(req: ServiceRequest, credentials: BearerAuth) -> Result { /// if credentials.token() == "mF_9.B5f-4.1JqM" { /// Ok(req) /// } else { @@ -105,7 +105,7 @@ where /// .unwrap_or_else(Default::default) /// .scope("urn:example:channel=HBO&urn:example:rating=G,PG-13"); /// - /// Err(AuthenticationError::from(config).into()) + /// Err((AuthenticationError::from(config).into(), req)) /// } /// } /// @@ -121,7 +121,7 @@ where S: Service, Error = Error> + 'static, S::Future: 'static, F: Fn(ServiceRequest, T) -> O + 'static, - O: Future> + 'static, + O: Future> + 'static, T: AuthExtractor + 'static, B: MessageBody + 'static, { @@ -155,7 +155,7 @@ where S: Service, Error = Error> + 'static, S::Future: 'static, F: Fn(ServiceRequest, T) -> O + 'static, - O: Future> + 'static, + O: Future> + 'static, T: AuthExtractor + 'static, B: MessageBody + 'static, { @@ -178,9 +178,12 @@ where } }; - // TODO: alter to remove ? operator; an error response is required for downstream - // middleware to do their thing (eg. cors adding headers) - let req = process_fn(req, credentials).await?; + let req = match process_fn(req, credentials).await { + Ok(req) => req, + Err((err, req)) => { + return Ok(req.error_response(err).map_into_right_body()); + } + }; service.call(req).await.map(|res| res.map_into_left_body()) } @@ -362,10 +365,10 @@ mod tests { #[actix_web::test] async fn test_middleware_works_with_app() { async fn validator( - _req: ServiceRequest, + req: ServiceRequest, _credentials: BasicAuth, - ) -> Result { - Err(ErrorForbidden("You are not welcome!")) + ) -> Result { + Err((ErrorForbidden("You are not welcome!"), req)) } let middleware = HttpAuthentication::basic(validator); @@ -387,10 +390,10 @@ mod tests { #[actix_web::test] async fn test_middleware_works_with_scope() { async fn validator( - _req: ServiceRequest, + req: ServiceRequest, _credentials: BasicAuth, - ) -> Result { - Err(ErrorForbidden("You are not welcome!")) + ) -> Result { + Err((ErrorForbidden("You are not welcome!"), req)) } let middleware = actix_web::middleware::Compat::new(HttpAuthentication::basic(validator)); diff --git a/clippy.toml b/clippy.toml index 0f31b88d4..5cccb362c 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1 +1 @@ -msrv = "1.54.0" +msrv = "1.57"