1
0
mirror of https://github.com/actix/actix-extras.git synced 2025-03-16 02:13:05 +01:00

Merge branch 'master' into master

This commit is contained in:
Rob Ede 2022-07-19 02:59:54 +02:00 committed by GitHub
commit cb1fbefbb2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 2015 additions and 1813 deletions

View File

@ -1,4 +1,4 @@
name: CI (master only) name: CI (post-merge)
on: on:
push: push:
@ -34,6 +34,9 @@ jobs:
profile: minimal profile: minimal
override: true override: true
- name: Install cargo-hack
uses: taiki-e/install-action@cargo-hack
- name: Generate Cargo.lock - name: Generate Cargo.lock
uses: actions-rs/cargo@v1 uses: actions-rs/cargo@v1
with: with:
@ -41,12 +44,6 @@ jobs:
- name: Cache Dependencies - name: Cache Dependencies
uses: Swatinem/rust-cache@v1.2.0 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 - name: check minimal
uses: actions-rs/cargo@v1 uses: actions-rs/cargo@v1
with: { command: ci-min } with: { command: ci-min }
@ -92,6 +89,9 @@ jobs:
profile: minimal profile: minimal
override: true override: true
- name: Install cargo-hack
uses: taiki-e/install-action@cargo-hack
- name: Generate Cargo.lock - name: Generate Cargo.lock
uses: actions-rs/cargo@v1 uses: actions-rs/cargo@v1
with: with:
@ -99,12 +99,6 @@ jobs:
- name: Cache Dependencies - name: Cache Dependencies
uses: Swatinem/rust-cache@v1.2.0 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 - name: check minimal
uses: actions-rs/cargo@v1 uses: actions-rs/cargo@v1
with: { command: ci-min } with: { command: ci-min }

View File

@ -14,7 +14,7 @@ jobs:
target: target:
- { name: Linux, os: ubuntu-latest, triple: x86_64-unknown-linux-gnu } - { name: Linux, os: ubuntu-latest, triple: x86_64-unknown-linux-gnu }
version: version:
- 1.54.0 # MSRV - 1.57 # MSRV
- stable - stable
name: ${{ matrix.target.name }} / ${{ matrix.version }} name: ${{ matrix.target.name }} / ${{ matrix.version }}
@ -42,6 +42,9 @@ jobs:
profile: minimal profile: minimal
override: true override: true
- name: Install cargo-hack
uses: taiki-e/install-action@cargo-hack
- name: Generate Cargo.lock - name: Generate Cargo.lock
uses: actions-rs/cargo@v1 uses: actions-rs/cargo@v1
with: with:
@ -49,12 +52,6 @@ jobs:
- name: Cache Dependencies - name: Cache Dependencies
uses: Swatinem/rust-cache@v1.2.0 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 - name: check minimal
uses: actions-rs/cargo@v1 uses: actions-rs/cargo@v1
with: { command: ci-min } with: { command: ci-min }
@ -85,7 +82,7 @@ jobs:
- { name: macOS, os: macos-latest, triple: x86_64-apple-darwin } - { name: macOS, os: macos-latest, triple: x86_64-apple-darwin }
- { name: Windows, os: windows-latest, triple: x86_64-pc-windows-msvc } - { name: Windows, os: windows-latest, triple: x86_64-pc-windows-msvc }
version: version:
- 1.54.0 # MSRV - 1.57 # MSRV
- stable - stable
name: ${{ matrix.target.name }} / ${{ matrix.version }} name: ${{ matrix.target.name }} / ${{ matrix.version }}
@ -101,6 +98,9 @@ jobs:
profile: minimal profile: minimal
override: true override: true
- name: Install cargo-hack
uses: taiki-e/install-action@cargo-hack
- name: Generate Cargo.lock - name: Generate Cargo.lock
uses: actions-rs/cargo@v1 uses: actions-rs/cargo@v1
with: with:
@ -108,12 +108,6 @@ jobs:
- name: Cache Dependencies - name: Cache Dependencies
uses: Swatinem/rust-cache@v1.2.0 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 - name: check minimal
uses: actions-rs/cargo@v1 uses: actions-rs/cargo@v1
with: { command: ci-min } with: { command: ci-min }

View File

@ -10,9 +10,6 @@ members = [
"actix-web-httpauth", "actix-web-httpauth",
] ]
# TODO: move this example to examples repo
# "actix-protobuf/examples/prost-example",
[patch.crates-io] [patch.crates-io]
actix-cors = { path = "./actix-cors" } actix-cors = { path = "./actix-cors" }
actix-identity = { path = "./actix-identity" } actix-identity = { path = "./actix-identity" }

View File

@ -25,21 +25,22 @@
These crates are provided by the community. These crates are provided by the community.
| Crate | | | | 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-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-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-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-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-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-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-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-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-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-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-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-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. | | [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. |
| [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-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. |
| [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`. | | [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. |
| [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. | | [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`. |
| [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. | | [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. |
| [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. | | [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-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. | | [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. 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]: https://github.com/actix/actix
[actix web]: https://github.com/actix/actix-web [actix web]: https://github.com/actix/actix-web
[actix-extras]: https://github.com/actix/actix-extras [actix-extras]: https://github.com/actix/actix-extras
[actix-cors]: actix-cors [actix-cors]: ./actix-cors
[actix-identity]: actix-identity [actix-identity]: ./actix-identity
[actix-limitation]: actix-limitation [actix-limitation]: ./actix-limitation
[actix-protobuf]: actix-protobuf [actix-protobuf]: ./actix-protobuf
[actix-redis]: actix-redis [actix-redis]: ./actix-redis
[actix-session]: actix-session [actix-session]: ./actix-session
[actix-web-httpauth]: actix-web-httpauth [actix-web-httpauth]: ./actix-web-httpauth
[actix-web-lab]: https://github.com/robjtede/actix-web-lab/tree/main/actix-web-lab [actix-web-lab]: https://crates.io/crates/actix-web-lab
[actix-form-data]: https://git.asonix.dog/asonix/actix-form-data [actix-multipart-extract]: https://crates.io/crates/actix-multipart-extract
[actix-casbin]: https://github.com/casbin-rs/actix-casbin [actix-form-data]: https://crates.io/crates/actix-form-data
[actix-ip-filter]: https://github.com/jhen0409/actix-ip-filter [actix-casbin]: https://crates.io/crates/actix-casbin
[actix-web-static-files]: https://github.com/kilork/actix-web-static-files [actix-ip-filter]: https://crates.io/crates/actix-ip-filter
[actix-web-grants]: https://github.com/DDtKey/actix-web-grants [actix-web-static-files]: https://crates.io/crates/actix-web-static-files
[actix-web-flash-messages]: https://github.com/LukeMathWalker/actix-web-flash-messages [actix-web-grants]: https://crates.io/crates/actix-web-grants
[actix-governor]: https://github.com/AaronErhardt/actix-governor [actix-web-flash-messages]: https://crates.io/crates/actix-web-flash-messages
[aliri_actix]: https://github.com/neoeinstein/aliri [actix-governor]: https://crates.io/crates/actix-governor
[awmp]: https://github.com/kardeiz/awmp [aliri_actix]: https://crates.io/crates/aliri_actix
[tracing-actix-web]: https://github.com/LukeMathWalker/tracing-actix-web [awmp]: https://crates.io/crates/awmp
[actix-ws]: https://git.asonix.dog/asonix/actix-actorless-websockets [tracing-actix-web]: https://crates.io/crates/tracing-actix-web
[actix-hash]: https://github.com/robjtede/actix-web-lab/tree/main/actix-hash [actix-ws]: https://crates.io/crates/actix-ws
[actix-hash]: https://crates.io/crates/actix-hash

View File

@ -1,6 +1,7 @@
# Changes # 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 ## 0.6.1 - 2022-03-07

View File

@ -11,4 +11,4 @@
- [API Documentation](https://docs.rs/actix-cors) - [API Documentation](https://docs.rs/actix-cors)
- [Example Project](https://github.com/actix/examples/tree/master/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

View File

@ -1,5 +1,5 @@
/// An enum signifying that some of type `T` is allowed, or `All` (anything is allowed). /// 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<T> { pub enum AllOrSome<T> {
/// Everything is allowed. Usually equivalent to the `*` value. /// Everything is allowed. Usually equivalent to the `*` value.
All, All,

View File

@ -1,6 +1,54 @@
# Changes # 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<Identity>` or a `Result<Identity, actix_web::Error>` 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<Identity>) -> 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 ## 0.4.0 - 2022-03-01

View File

@ -1,7 +1,10 @@
[package] [package]
name = "actix-identity" name = "actix-identity"
version = "0.4.0" version = "0.5.2"
authors = ["Nikolay Kim <fafhrd91@gmail.com>"] authors = [
"Nikolay Kim <fafhrd91@gmail.com>",
"Luca Palmieri <rust@lpalmieri.com>",
]
description = "Identity service for Actix Web" description = "Identity service for Actix Web"
keywords = ["actix", "auth", "identity", "web", "security"] keywords = ["actix", "auth", "identity", "web", "security"]
homepage = "https://actix.rs" homepage = "https://actix.rs"
@ -15,14 +18,20 @@ path = "src/lib.rs"
[dependencies] [dependencies]
actix-service = "2" actix-service = "2"
actix-session = "0.7"
actix-utils = "3" actix-utils = "3"
actix-web = { version = "4", default-features = false, features = ["cookies", "secure-cookies"] } 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 = { version = "1", features = ["derive"] }
serde_json = "1" tracing = { version = "0.1.30", default-features = false, features = ["log"] }
time = "0.3"
[dev-dependencies] [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-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"] }

View File

@ -3,11 +3,11 @@
> Identity service for actix-web framework. > Identity service for actix-web framework.
[![crates.io](https://img.shields.io/crates/v/actix-identity?label=latest)](https://crates.io/crates/actix-identity) [![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) ![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 ## Documentation & community resources
* [API Documentation](https://docs.rs/actix-identity) * [API Documentation](https://docs.rs/actix-identity)
* Minimum Supported Rust Version (MSRV): 1.54 * Minimum Supported Rust Version (MSRV): 1.57

View File

@ -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<Identity>) -> 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()
}

View File

@ -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<Duration>,
pub(crate) visit_deadline: Option<Duration>,
}
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<Duration>) -> 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<Duration>) -> Self {
self.configuration.visit_deadline = deadline;
self
}
/// Finalises the builder and returns an [`IdentityMiddleware`] instance.
pub fn build(self) -> IdentityMiddleware {
IdentityMiddleware::new(self.configuration)
}
}

View File

@ -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<String>,
secure: bool,
max_age: Option<Duration>,
http_only: Option<bool>,
same_site: Option<SameSite>,
visit_deadline: Option<Duration>,
login_deadline: Option<Duration>,
}
#[derive(Debug, Deserialize, Serialize)]
struct CookieValue {
identity: String,
#[serde(skip_serializing_if = "Option::is_none")]
login_timestamp: Option<SystemTime>,
#[serde(skip_serializing_if = "Option::is_none")]
visit_timestamp: Option<SystemTime>,
}
#[derive(Debug)]
struct CookieIdentityExtension {
login_timestamp: Option<SystemTime>,
}
impl CookieIdentityInner {
fn new(key: &[u8]) -> CookieIdentityInner {
let key_v2: Vec<u8> = [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<B>(
&self,
resp: &mut ServiceResponse<B>,
value: Option<CookieValue>,
) -> 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<CookieValue> {
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<CookieValue> {
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<CookieIdentityInner>);
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<String>) -> CookieIdentityPolicy {
self.inner_mut().name = value.into();
self
}
/// Sets the `Path` attribute of issued cookies.
pub fn path(mut self, value: impl Into<String>) -> CookieIdentityPolicy {
self.inner_mut().path = value.into();
self
}
/// Sets the `Domain` attribute of issued cookies.
pub fn domain(mut self, value: impl Into<String>) -> 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<Result<Option<String>, Error>>;
type ResponseFuture = Ready<Result<(), Error>>;
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<B>(
&self,
id: Option<String>,
changed: bool,
res: &mut ServiceResponse<B>,
) -> 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<SystemTime>,
visit_timestamp: Option<SystemTime>,
) -> Cookie<'static> {
let mut jar = CookieJar::new();
let key: Vec<u8> = 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<EitherBody<BoxBody>>,
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<u8> = 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<EitherBody<BoxBody>>,
identity: Option<&str>,
) {
let bytes = test::read_body(response).await;
let resp: Option<String> = serde_json::from_slice(&bytes[..]).unwrap();
assert_eq!(resp.as_ref().map(|s| s.borrow()), identity);
}
fn assert_legacy_login_cookie(
response: &mut ServiceResponse<EitherBody<BoxBody>>,
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<EitherBody<BoxBody>>) {
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;
}
}

View File

@ -1,89 +1,247 @@
use actix_session::Session;
use actix_utils::future::{ready, Ready}; use actix_utils::future::{ready, Ready};
use actix_web::{ use actix_web::{
cookie::time::OffsetDateTime,
dev::{Extensions, Payload}, dev::{Extensions, Payload},
Error, FromRequest, HttpMessage as _, HttpRequest, http::StatusCode,
Error, FromRequest, HttpMessage, HttpRequest, HttpResponse,
}; };
use anyhow::{anyhow, Context};
pub(crate) struct IdentityItem { use crate::config::LogoutBehaviour;
pub(crate) id: Option<String>,
pub(crate) changed: bool,
}
/// 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; /// use actix_identity::Identity;
/// ///
/// #[get("/")] /// #[get("/")]
/// async fn index(id: Identity) -> impl Responder { /// async fn index(user: Option<Identity>) -> impl Responder {
/// // access request identity /// if let Some(user) = user {
/// if let Some(id) = id.identity() { /// format!("Welcome! {}", user.id().unwrap())
/// format!("Welcome! {}", id)
/// } else { /// } else {
/// "Welcome Anonymous!".to_owned() /// "Welcome Anonymous!".to_owned()
/// } /// }
/// } /// }
/// ///
/// #[post("/login")] /// #[post("/login")]
/// async fn login(id: Identity) -> impl Responder { /// async fn login(request: HttpRequest) -> impl Responder {
/// // remember identity /// Identity::login(&request.extensions(), "User1".into());
/// id.remember("User1".to_owned());
///
/// HttpResponse::Ok() /// HttpResponse::Ok()
/// } /// }
/// ///
/// #[post("/logout")] /// #[post("/logout")]
/// async fn logout(id: Identity) -> impl Responder { /// async fn logout(user: Identity) -> impl Responder {
/// // remove identity /// user.logout();
/// id.forget();
///
/// HttpResponse::Ok() /// 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<String> {
Identity::get_identity(&self.0.extensions())
}
/// Remember identity.
pub fn remember(&self, identity: String) {
if let Some(id) = self.0.extensions_mut().get_mut::<IdentityItem>() {
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::<IdentityItem>() {
id.id = None;
id.changed = true;
}
}
pub(crate) fn get_identity(extensions: &Extensions) -> Option<String> {
let id = extensions.get::<IdentityItem>()?;
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<Identity>` or
/// `Result<Identity, actix_web::Error>` 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; /// use actix_identity::Identity;
/// ///
/// #[get("/")] /// #[get("/")]
/// async fn index(id: Identity) -> impl Responder { /// async fn index(user: Option<Identity>) -> impl Responder {
/// // access request identity /// if let Some(user) = user {
/// if let Some(id) = id.identity() { /// HttpResponse::Ok().finish()
/// format!("Welcome! {}", id) /// } 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::<Self>()
.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<String, anyhow::Error> {
self.session
.get::<String>(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<Identity>) -> impl Responder {
/// if let Some(user) = user {
/// format!("Welcome! {}", user.id().unwrap())
/// } else {
/// "Welcome Anonymous!".to_owned()
/// }
/// }
/// ```
pub fn id(&self) -> Result<String, anyhow::Error> {
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<Self, anyhow::Error> {
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<Self, anyhow::Error> {
let inner = IdentityInner::extract(ext);
inner.get_identity()?;
Ok(Self(inner))
}
pub(crate) fn logged_at(&self) -> Result<Option<OffsetDateTime>, 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<Option<OffsetDateTime>, 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<Identity>) -> impl Responder {
/// if let Some(user) = user {
/// format!("Welcome! {}", user.id().unwrap())
/// } else { /// } else {
/// "Welcome Anonymous!".to_owned() /// "Welcome Anonymous!".to_owned()
/// } /// }
@ -91,10 +249,17 @@ impl Identity {
/// ``` /// ```
impl FromRequest for Identity { impl FromRequest for Identity {
type Error = Error; type Error = Error;
type Future = Ready<Result<Identity, Error>>; type Future = Ready<Result<Self, Self::Error>>;
#[inline] #[inline]
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { 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)
}))
} }
} }

View File

@ -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<Identity, anyhow::Error>;
}
impl IdentityExt for HttpRequest {
fn get_identity(&self) -> Result<Identity, anyhow::Error> {
Identity::extract(&self.extensions())
}
}
impl IdentityExt for ServiceRequest {
fn get_identity(&self) -> Result<Identity, anyhow::Error> {
Identity::extract(&self.extensions())
}
}
impl<'a> IdentityExt for GuardContext<'a> {
fn get_identity(&self) -> Result<Identity, anyhow::Error> {
Identity::extract(&self.req_data())
}
}

View File

@ -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 //! `actix-identity` can be used to track identity of a user across multiple requests. It is built
//! identity information. //! 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("/")] //! #[get("/")]
//! async fn index(id: Identity) -> String { //! async fn index(user: Option<Identity>) -> impl Responder {
//! // access request identity //! if let Some(user) = user {
//! if let Some(id) = id.identity() { //! format!("Welcome! {}", user.id().unwrap())
//! format!("Welcome! {}", id)
//! } else { //! } else {
//! "Welcome Anonymous!".to_owned() //! "Welcome Anonymous!".to_owned()
//! } //! }
//! } //! }
//! //!
//! #[post("/login")] //! #[post("/login")]
//! async fn login(id: Identity) -> HttpResponse { //! async fn login(request: HttpRequest) -> impl Responder {
//! // remember identity //! // Some kind of authentication should happen here
//! id.remember("User1".to_owned()); //! // e.g. password-based, biometric, etc.
//! HttpResponse::Ok().finish() //! // [...]
//!
//! // attach a verified user identity to the active session
//! Identity::login(&request.extensions(), "User1".into()).unwrap();
//!
//! HttpResponse::Ok()
//! } //! }
//! //!
//! #[post("/logout")] //! #[post("/logout")]
//! async fn logout(id: Identity) -> HttpResponse { //! async fn logout(user: Identity) -> impl Responder {
//! // remove identity //! user.logout();
//! id.forget(); //! HttpResponse::Ok()
//! HttpResponse::Ok().finish()
//! } //! }
//!
//! 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)] #![warn(future_incompatible)]
use std::future::Future; pub mod config;
use actix_web::{
dev::{ServiceRequest, ServiceResponse},
Error, HttpMessage, Result,
};
mod cookie;
mod identity; mod identity;
mod identity_ext;
mod middleware; mod middleware;
pub use self::cookie::CookieIdentityPolicy;
pub use self::identity::Identity; pub use self::identity::Identity;
pub use self::middleware::IdentityService; pub use self::identity_ext::IdentityExt;
pub use self::middleware::IdentityMiddleware;
/// Identity policy.
pub trait IdentityPolicy: Sized + 'static {
/// The return type of the middleware
type Future: Future<Output = Result<Option<String>, Error>>;
/// The return type of the middleware
type ResponseFuture: Future<Output = Result<(), Error>>;
/// 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<B>(
&self,
identity: Option<String>,
changed: bool,
response: &mut ServiceResponse<B>,
) -> 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<String>;
}
impl<T> RequestIdentity for T
where
T: HttpMessage,
{
fn get_identity(&self) -> Option<String> {
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<EitherBody<BoxBody>>,
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
}
}

View File

@ -1,171 +1,258 @@
use std::rc::Rc; use std::rc::Rc;
use actix_session::SessionExt;
use actix_utils::future::{ready, Ready}; use actix_utils::future::{ready, Ready};
use actix_web::{ use actix_web::{
body::{EitherBody, MessageBody}, body::MessageBody,
cookie::time::{format_description::well_known::Rfc3339, OffsetDateTime},
dev::{Service, ServiceRequest, ServiceResponse, Transform}, 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; #[derive(Default, Clone)]
/// use actix_identity::{CookieIdentityPolicy, IdentityService}; pub struct IdentityMiddleware {
/// configuration: Rc<Configuration>,
/// // 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<T> {
backend: Rc<T>,
} }
impl<T> IdentityService<T> { impl IdentityMiddleware {
/// Create new identity service with specified backend. pub(crate) fn new(configuration: Configuration) -> Self {
pub fn new(backend: T) -> Self { Self {
IdentityService { configuration: Rc::new(configuration),
backend: Rc::new(backend),
} }
} }
/// A fluent API to configure [`IdentityMiddleware`].
pub fn builder() -> IdentityMiddlewareBuilder {
IdentityMiddlewareBuilder::new()
}
} }
impl<S, T, B> Transform<S, ServiceRequest> for IdentityService<T> impl<S, B> Transform<S, ServiceRequest> for IdentityMiddleware
where where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static, S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static, S::Future: 'static,
T: IdentityPolicy,
B: MessageBody + 'static, B: MessageBody + 'static,
{ {
type Response = ServiceResponse<EitherBody<B>>; type Response = ServiceResponse<B>;
type Error = Error; type Error = Error;
type Transform = InnerIdentityMiddleware<S>;
type InitError = (); type InitError = ();
type Transform = IdentityServiceMiddleware<S, T>;
type Future = Ready<Result<Self::Transform, Self::InitError>>; type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future { fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(IdentityServiceMiddleware { ready(Ok(InnerIdentityMiddleware {
backend: self.backend.clone(),
service: Rc::new(service), service: Rc::new(service),
configuration: Rc::clone(&self.configuration),
})) }))
} }
} }
pub struct IdentityServiceMiddleware<S, T> { #[doc(hidden)]
pub(crate) service: Rc<S>, pub struct InnerIdentityMiddleware<S> {
pub(crate) backend: Rc<T>, service: Rc<S>,
configuration: Rc<Configuration>,
} }
impl<S, T> Clone for IdentityServiceMiddleware<S, T> { impl<S> Clone for InnerIdentityMiddleware<S> {
fn clone(&self) -> Self { fn clone(&self) -> Self {
Self { Self {
backend: Rc::clone(&self.backend),
service: Rc::clone(&self.service), service: Rc::clone(&self.service),
configuration: Rc::clone(&self.configuration),
} }
} }
} }
impl<S, T, B> Service<ServiceRequest> for IdentityServiceMiddleware<S, T> impl<S, B> Service<ServiceRequest> for InnerIdentityMiddleware<S>
where where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static, S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static, S::Future: 'static,
T: IdentityPolicy,
B: MessageBody + 'static, B: MessageBody + 'static,
{ {
type Response = ServiceResponse<EitherBody<B>>; type Response = ServiceResponse<B>;
type Error = Error; type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>; type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
actix_service::forward_ready!(service); 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 srv = Rc::clone(&self.service);
let backend = Rc::clone(&self.backend); let configuration = Rc::clone(&self.configuration);
let fut = self.backend.from_request(&mut req); Box::pin(async move {
let identity_inner = IdentityInner {
async move { session: req.get_session(),
match fut.await { logout_behaviour: configuration.on_logout.clone(),
Ok(id) => { is_login_deadline_enabled: configuration.login_deadline.is_some(),
req.extensions_mut() is_visit_deadline_enabled: configuration.visit_deadline.is_some(),
.insert(IdentityItem { id, changed: false }); };
req.extensions_mut().insert(identity_inner);
let mut res = srv.call(req).await?; enforce_policies(&req, &configuration);
let id = res.request().extensions_mut().remove::<IdentityItem>(); srv.call(req).await
})
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()
} }
} }
#[cfg(test)] // easier to scan with returns where they are
mod tests { // especially if the function body were to evolve in the future
use std::{rc::Rc, time::Duration}; #[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; if !must_extract_identity {
use actix_web::{dev, error, test, Error, Result}; 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] if let Some(login_deadline) = configuration.login_deadline {
async fn test_borrowed_mut_error() { if matches!(
use actix_utils::future::{ok, Ready}; enforce_login_deadline(&identity, login_deadline),
use futures_util::future::lazy; PolicyDecision::LogOut
) {
identity.logout();
return;
}
}
struct Ident; if let Some(visit_deadline) = configuration.visit_deadline {
impl IdentityPolicy for Ident { if matches!(
type Future = Ready<Result<Option<String>, Error>>; enforce_visit_deadline(&identity, visit_deadline),
type ResponseFuture = Ready<Result<(), Error>>; PolicyDecision::LogOut
) {
fn from_request(&self, _: &mut dev::ServiceRequest) -> Self::Future { identity.logout();
ok(Some("test".to_string())) return;
} } else {
if let Err(err) = identity.set_last_visited_at() {
fn to_response<B>( tracing::warn!(
&self, error.display = %err,
_: Option<String>, error.debug = ?err,
_: bool, "Failed to set the last visited timestamp on `Identity` for an incoming request."
_: &mut dev::ServiceResponse<B>, );
) -> Self::ResponseFuture {
ok(())
} }
} }
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::<dev::ServiceResponse, _>(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,
}

View File

@ -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<CookieSessionStore> {
SessionMiddleware::builder(store(), Key::generate())
.cookie_domain(Some("localhost".into()))
.build()
}

View File

@ -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);
}

View File

@ -0,0 +1,3 @@
pub mod fixtures;
mod integration;
pub mod test_app;

View File

@ -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<String>,
pub counter: i32,
pub session_status: String,
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct LoginRequest {
user_id: String,
}
async fn show(user: Option<Identity>, session: Session) -> HttpResponse {
let user_id = user.map(|u| u.id().unwrap());
let counter: i32 = session
.get::<i32>("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<Identity>) -> HttpResponse {
let user_id = user.map(|u| u.id().unwrap());
let counter: i32 = session
.get::<i32>("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<LoginRequest>,
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::<i32>("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<Identity>) -> 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()
}

View File

@ -3,6 +3,12 @@
## Unreleased - 2022-xx-xx ## Unreleased - 2022-xx-xx
## 0.3.0 - 2022-07-11
- `Limiter::builder` now takes an `impl Into<String>`.
- Removed lifetime from `Builder`.
- Updated `actix-session` dependency to `0.7`.
## 0.2.0 - 2022-03-22 ## 0.2.0 - 2022-03-22
- Update Actix Web dependency to v4 ecosystem. [#229] - Update Actix Web dependency to v4 ecosystem. [#229]
- Update Tokio dependencies to v1 ecosystem. [#229] - Update Tokio dependencies to v1 ecosystem. [#229]

View File

@ -1,6 +1,6 @@
[package] [package]
name = "actix-limitation" name = "actix-limitation"
version = "0.2.0" version = "0.3.0"
authors = [ authors = [
"0xmad <0xmad@users.noreply.github.com>", "0xmad <0xmad@users.noreply.github.com>",
"Rob Ede <robjtede@icloud.com>", "Rob Ede <robjtede@icloud.com>",
@ -13,7 +13,7 @@ license = "MIT OR Apache-2.0"
edition = "2018" edition = "2018"
[dependencies] [dependencies]
actix-session = "0.5" actix-session = "0.7"
actix-utils = "3" actix-utils = "3"
actix-web = { version = "4", default-features = false } actix-web = { version = "4", default-features = false }
@ -25,5 +25,5 @@ time = "0.3"
[dev-dependencies] [dev-dependencies]
actix-web = "4" actix-web = "4"
uuid = { version = "0.8", features = ["v4"] }
static_assertions = "1" static_assertions = "1"
uuid = { version = "1", features = ["v4"] }

View File

@ -4,16 +4,16 @@
> Originally based on <https://github.com/fnichol/limitation>. > Originally based on <https://github.com/fnichol/limitation>.
[![crates.io](https://img.shields.io/crates/v/actix-limitation?label=latest)](https://crates.io/crates/actix-limitation) [![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) ![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 ## Examples
```toml ```toml
[dependencies] [dependencies]
actix-web = "4" actix-web = "4"
actix-limitation = "0.1.4" actix-limitation = "0.3"
``` ```
```rust ```rust

View File

@ -4,17 +4,17 @@ use redis::Client;
use crate::{errors::Error, Limiter}; use crate::{errors::Error, Limiter};
/// Rate limit builder. /// Rate limiter builder.
#[derive(Debug)] #[derive(Debug)]
pub struct Builder<'a> { pub struct Builder {
pub(crate) redis_url: &'a str, pub(crate) redis_url: String,
pub(crate) limit: usize, pub(crate) limit: usize,
pub(crate) period: Duration, pub(crate) period: Duration,
pub(crate) cookie_name: Cow<'static, str>, pub(crate) cookie_name: Cow<'static, str>,
pub(crate) session_key: Cow<'static, str>, pub(crate) session_key: Cow<'static, str>,
} }
impl Builder<'_> { impl Builder {
/// Set upper limit. /// Set upper limit.
pub fn limit(&mut self, limit: usize) -> &mut Self { pub fn limit(&mut self, limit: usize) -> &mut Self {
self.limit = limit; self.limit = limit;
@ -45,7 +45,7 @@ impl Builder<'_> {
/// **synchronous** operation. /// **synchronous** operation.
pub fn build(&self) -> Result<Limiter, Error> { pub fn build(&self) -> Result<Limiter, Error> {
Ok(Limiter { Ok(Limiter {
client: Client::open(self.redis_url)?, client: Client::open(self.redis_url.as_str())?,
limit: self.limit, limit: self.limit,
period: self.period, period: self.period,
cookie_name: self.cookie_name.clone(), cookie_name: self.cookie_name.clone(),
@ -63,7 +63,7 @@ mod tests {
let redis_url = "redis://127.0.0.1"; let redis_url = "redis://127.0.0.1";
let period = Duration::from_secs(10); let period = Duration::from_secs(10);
let builder = Builder { let builder = Builder {
redis_url, redis_url: redis_url.to_owned(),
limit: 100, limit: 100,
period, period,
cookie_name: Cow::Owned("session".to_string()), cookie_name: Cow::Owned("session".to_string()),
@ -82,7 +82,7 @@ mod tests {
let redis_url = "redis://127.0.0.1"; let redis_url = "redis://127.0.0.1";
let period = Duration::from_secs(20); let period = Duration::from_secs(20);
let mut builder = Builder { let mut builder = Builder {
redis_url, redis_url: redis_url.to_owned(),
limit: 100, limit: 100,
period: Duration::from_secs(10), period: Duration::from_secs(10),
session_key: Cow::Borrowed("key"), session_key: Cow::Borrowed("key"),
@ -109,7 +109,7 @@ mod tests {
let redis_url = "127.0.0.1"; let redis_url = "127.0.0.1";
let period = Duration::from_secs(20); let period = Duration::from_secs(20);
let mut builder = Builder { let mut builder = Builder {
redis_url, redis_url: redis_url.to_owned(),
limit: 100, limit: 100,
period: Duration::from_secs(10), period: Duration::from_secs(10),
session_key: Cow::Borrowed("key"), session_key: Cow::Borrowed("key"),

View File

@ -3,7 +3,7 @@
//! ```toml //! ```toml
//! [dependencies] //! [dependencies]
//! actix-web = "4" //! actix-web = "4"
//! actix-limitation = "0.1.4" #![doc = concat!("actix-limitation = \"", env!("CARGO_PKG_VERSION_MAJOR"), ".", env!("CARGO_PKG_VERSION_MINOR"),"\"")]
//! ``` //! ```
//! //!
//! ```no_run //! ```no_run
@ -34,7 +34,7 @@
//! .app_data(limiter.clone()) //! .app_data(limiter.clone())
//! .service(index) //! .service(index)
//! }) //! })
//! .bind("127.0.0.1:8080")? //! .bind(("127.0.0.1", 8080))?
//! .run() //! .run()
//! .await //! .await
//! } //! }
@ -73,7 +73,7 @@ pub const DEFAULT_COOKIE_NAME: &str = "sid";
pub const DEFAULT_SESSION_KEY: &str = "rate-api-id"; pub const DEFAULT_SESSION_KEY: &str = "rate-api-id";
/// Rate limiter. /// Rate limiter.
#[derive(Clone, Debug)] #[derive(Debug, Clone)]
pub struct Limiter { pub struct Limiter {
client: Client, client: Client,
limit: usize, limit: usize,
@ -88,9 +88,9 @@ impl Limiter {
/// See [`redis-rs` docs](https://docs.rs/redis/0.21/redis/#connection-parameters) on connection /// See [`redis-rs` docs](https://docs.rs/redis/0.21/redis/#connection-parameters) on connection
/// parameters for how to set the Redis URL. /// parameters for how to set the Redis URL.
#[must_use] #[must_use]
pub fn builder(redis_url: &str) -> Builder<'_> { pub fn builder(redis_url: impl Into<String>) -> Builder {
Builder { Builder {
redis_url, redis_url: redis_url.into(),
limit: DEFAULT_REQUEST_LIMIT, limit: DEFAULT_REQUEST_LIMIT,
period: Duration::from_secs(DEFAULT_PERIOD_SECS), period: Duration::from_secs(DEFAULT_PERIOD_SECS),
cookie_name: Cow::Borrowed(DEFAULT_COOKIE_NAME), cookie_name: Cow::Borrowed(DEFAULT_COOKIE_NAME),

View File

@ -1,6 +1,6 @@
use std::{future::Future, pin::Pin, rc::Rc}; 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_utils::future::{ok, Ready};
use actix_web::{ use actix_web::{
body::EitherBody, body::EitherBody,

View File

@ -1,6 +1,11 @@
# Changes # 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 ## 0.7.0 - 2022-03-01

View File

@ -1,17 +1,16 @@
[package] [package]
name = "actix-protobuf" name = "actix-protobuf"
version = "0.7.0" version = "0.8.0"
edition = "2018" edition = "2018"
authors = [ authors = [
"kingxsp <jin.hb.zh@outlook.com>", "kingxsp <jin.hb.zh@outlook.com>",
"Yuki Okushi <huyuumi.dev@gmail.com>" "Yuki Okushi <huyuumi.dev@gmail.com>",
] ]
description = "Protobuf support for Actix web" description = "Protobuf support for Actix Web"
keywords = ["actix", "protobuf", "protocol", "rpc"] keywords = ["actix", "web", "protobuf", "protocol", "rpc"]
homepage = "https://actix.rs" homepage = "https://actix.rs"
repository = "https://github.com/actix/actix-extras.git" repository = "https://github.com/actix/actix-extras.git"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
exclude = [".cargo/config", "/examples/**"]
[lib] [lib]
name = "actix_protobuf" name = "actix_protobuf"
@ -21,8 +20,8 @@ path = "src/lib.rs"
actix-web = { version = "4", default_features = false } actix-web = { version = "4", default_features = false }
derive_more = "0.99.5" derive_more = "0.99.5"
futures-util = { version = "0.3.7", default-features = false } 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] [dev-dependencies]
actix-web = { version = "4", default_features = false, features = ["macros"] } 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"] }

View File

@ -3,15 +3,15 @@
> Protobuf support for Actix Web. > Protobuf support for Actix Web.
[![crates.io](https://img.shields.io/crates/v/actix-protobuf?label=latest)](https://crates.io/crates/actix-protobuf) [![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) ![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 ## Documentation & Resources
- [API Documentation](https://docs.rs/actix-protobuf) - [API Documentation](https://docs.rs/actix-protobuf)
- [Example Project](https://github.com/actix/examples/tree/master/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 ## Example

View File

@ -1,15 +0,0 @@
[package]
name = "prost-example"
version = "0.5.1"
edition = "2018"
authors = [
"kingxsp <jin.hb.zh@outlook.com>",
"Yuki Okushi <huyuumi.dev@gmail.com>"
]
[dependencies]
actix-web = "4"
actix-protobuf = { path = "../../" }
env_logger = "0.8"
prost = { version = "0.8", default_features = false, features = ["prost-derive"] }

View File

@ -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()

View File

@ -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<MyObj>) -> Result<HttpResponse> {
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
}

View File

@ -1,6 +0,0 @@
syntax = "proto3";
message MyObj {
int32 number = 1;
string name = 2;
}

View File

@ -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)

View File

@ -1,6 +1,13 @@
# Changes # 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 ## 0.11.0 - 2022-03-15

View File

@ -1,16 +1,19 @@
[package] [package]
name = "actix-redis" name = "actix-redis"
version = "0.11.0" version = "0.12.0"
authors = ["Nikolay Kim <fafhrd91@gmail.com>"] authors = ["Nikolay Kim <fafhrd91@gmail.com>"]
description = "Redis integration for Actix" description = "Redis integration for Actix"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
keywords = ["actix", "redis", "async", "session"] keywords = ["actix", "redis", "async"]
homepage = "https://actix.rs" homepage = "https://actix.rs"
repository = "https://github.com/actix/actix-extras.git" repository = "https://github.com/actix/actix-extras.git"
categories = ["network-programming", "asynchronous"] categories = ["network-programming", "asynchronous"]
exclude = [".cargo/config"]
edition = "2018" edition = "2018"
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[lib] [lib]
name = "actix_redis" name = "actix_redis"
path = "src/lib.rs" path = "src/lib.rs"
@ -22,7 +25,7 @@ default = ["web"]
web = ["actix-web"] web = ["actix-web"]
[dependencies] [dependencies]
actix = { version = "0.12", default-features = false } actix = { version = "0.13", default-features = false }
actix-rt = { version = "2.1", default-features = false } actix-rt = { version = "2.1", default-features = false }
actix-service = "2" actix-service = "2"
actix-tls = { version = "3", default-features = false, features = ["connect"] } actix-tls = { version = "3", default-features = false, features = ["connect"] }
@ -31,10 +34,10 @@ log = "0.4.6"
backoff = "0.4.0" backoff = "0.4.0"
derive_more = "0.99.5" derive_more = "0.99.5"
futures-core = { version = "0.3.7", default-features = false } 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" time = "0.3"
tokio = { version = "1.13.1", features = ["sync"] } 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 } actix-web = { version = "4", default_features = false, optional = true }
[dev-dependencies] [dev-dependencies]

View File

@ -3,12 +3,12 @@
> Redis integration for Actix. > Redis integration for Actix.
[![crates.io](https://img.shields.io/crates/v/actix-redis?label=latest)](https://crates.io/crates/actix-redis) [![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) ![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 ## Documentation & Resources
- [API Documentation](https://docs.rs/actix-redis) - [API Documentation](https://docs.rs/actix-redis)
- [Example Project](https://github.com/actix/examples/tree/master/auth/redis-session) - [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

View File

@ -1,5 +1,6 @@
//! Redis integration for `actix`. //! Redis integration for `actix`.
#![forbid(unsafe_code)]
#![deny(rust_2018_idioms, nonstandard_style)] #![deny(rust_2018_idioms, nonstandard_style)]
#![warn(future_incompatible)] #![warn(future_incompatible)]

View File

@ -6,6 +6,23 @@
- Add an `empty()` function to `Session` for simple testing - 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 ## 0.6.2 - 2022-03-25
- Implement `SessionExt` for `GuardContext`. [#234] - Implement `SessionExt` for `GuardContext`. [#234]
- `RedisSessionStore` will prevent connection timeouts from causing user-visible errors. [#235] - `RedisSessionStore` will prevent connection timeouts from causing user-visible errors. [#235]

View File

@ -1,6 +1,6 @@
[package] [package]
name = "actix-session" name = "actix-session"
version = "0.6.2" version = "0.7.0"
authors = [ authors = [
"Nikolay Kim <fafhrd91@gmail.com>", "Nikolay Kim <fafhrd91@gmail.com>",
"Luca Palmieri <rust@lpalmieri.com>", "Luca Palmieri <rust@lpalmieri.com>",
@ -38,12 +38,11 @@ derive_more = "0.99.5"
rand = { version = "0.8", optional = true } rand = { version = "0.8", optional = true }
serde = { version = "1" } serde = { version = "1" }
serde_json = { version = "1" } serde_json = { version = "1" }
time = "0.3"
tracing = { version = "0.1.30", default-features = false, features = ["log"] } tracing = { version = "0.1.30", default-features = false, features = ["log"] }
# redis-actor-session # redis-actor-session
actix = { version = "0.12.0", default-features = false, optional = true } actix = { version = "0.13", default-features = false, optional = true }
actix-redis = { version = "0.11.0", optional = true } actix-redis = { version = "0.12", optional = true }
futures-core = { version = "0.3.7", default-features = false, optional = true } futures-core = { version = "0.3.7", default-features = false, optional = true }
# redis-rs-session # redis-rs-session

View File

@ -3,13 +3,12 @@
> Session management for Actix Web applications. > Session management for Actix Web applications.
[![crates.io](https://img.shields.io/crates/v/actix-session?label=latest)](https://crates.io/crates/actix-session) [![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) ![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 ## Documentation & Resources
- [API Documentation](https://docs.rs/actix-session) - [API Documentation](https://docs.rs/actix-session)
- [Example Projects](https://github.com/actix/examples/tree/master/auth/cookie-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

369
actix-session/src/config.rs Normal file
View File

@ -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<BrowserSession> for SessionLifecycle {
fn from(session: BrowserSession) -> Self {
Self::BrowserSession(session)
}
}
impl From<PersistentSession> 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<Store: SessionStore> {
storage_backend: Store,
configuration: Configuration,
}
impl<Store: SessionStore> SessionMiddlewareBuilder<Store> {
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<S: Into<SessionLifecycle>>(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<String>) -> 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<Store> {
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<String>,
pub(crate) max_age: Option<Duration>,
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(),
}
}

View File

@ -133,38 +133,32 @@
//! [`RedisSessionStore`]: storage::RedisSessionStore //! [`RedisSessionStore`]: storage::RedisSessionStore
//! [`RedisActorSessionStore`]: storage::RedisActorSessionStore //! [`RedisActorSessionStore`]: storage::RedisActorSessionStore
#![forbid(unsafe_code)]
#![deny(rust_2018_idioms, nonstandard_style)] #![deny(rust_2018_idioms, nonstandard_style)]
#![warn(future_incompatible, missing_docs)] #![warn(future_incompatible, missing_docs)]
#![doc(html_logo_url = "https://actix.rs/img/logo.png")] #![doc(html_logo_url = "https://actix.rs/img/logo.png")]
#![doc(html_favicon_url = "https://actix.rs/favicon.ico")] #![doc(html_favicon_url = "https://actix.rs/favicon.ico")]
#![cfg_attr(docsrs, feature(doc_cfg))] #![cfg_attr(docsrs, feature(doc_cfg))]
pub mod config;
mod middleware; mod middleware;
mod session; mod session;
mod session_ext; mod session_ext;
pub mod storage; pub mod storage;
pub use self::middleware::{ pub use self::middleware::SessionMiddleware;
CookieContentSecurity, SessionLength, SessionMiddleware, SessionMiddlewareBuilder, pub use self::session::{Session, SessionGetError, SessionInsertError, SessionStatus};
};
pub use self::session::{Session, SessionStatus};
pub use self::session_ext::SessionExt; pub use self::session_ext::SessionExt;
#[cfg(test)] #[cfg(test)]
pub mod test_helpers { pub mod test_helpers {
use actix_web::cookie::Key; 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. /// Generate a random cookie signing/encryption key.
pub fn key() -> Key { pub fn key() -> Key {
let signing_key: String = thread_rng() Key::generate()
.sample_iter(&Alphanumeric)
.take(64)
.map(char::from)
.collect();
Key::from(signing_key.as_bytes())
} }
/// A ready-to-go acceptance test suite to verify that sessions behave as expected /// 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::basic_workflow(store_builder.clone(), *policy).await;
acceptance_tests::expiration_is_refreshed_on_changes(store_builder.clone(), *policy) acceptance_tests::expiration_is_refreshed_on_changes(store_builder.clone(), *policy)
.await; .await;
acceptance_tests::expiration_is_always_refreshed_if_configured_to_refresh_on_every_request(
store_builder.clone(),
*policy,
)
.await;
acceptance_tests::complex_workflow( acceptance_tests::complex_workflow(
store_builder.clone(), store_builder.clone(),
is_invalidation_supported, is_invalidation_supported,
@ -199,18 +198,18 @@ pub mod test_helpers {
mod acceptance_tests { mod acceptance_tests {
use actix_web::{ use actix_web::{
dev::Service, cookie::time,
dev::{Service, ServiceResponse},
guard, middleware, test, guard, middleware, test,
web::{self, get, post, resource, Bytes}, web::{self, get, post, resource, Bytes},
App, HttpResponse, Result, App, HttpResponse, Result,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use time::Duration;
use crate::config::{CookieContentSecurity, PersistentSession, TtlExtensionPolicy};
use crate::{ use crate::{
middleware::SessionLength, storage::SessionStore, test_helpers::key, storage::SessionStore, test_helpers::key, Session, SessionExt, SessionMiddleware,
CookieContentSecurity, Session, SessionExt, SessionMiddleware,
}; };
pub(super) async fn basic_workflow<F, Store>( pub(super) async fn basic_workflow<F, Store>(
@ -228,9 +227,10 @@ pub mod test_helpers {
.cookie_name("actix-test".into()) .cookie_name("actix-test".into())
.cookie_domain(Some("localhost".into())) .cookie_domain(Some("localhost".into()))
.cookie_content_security(policy) .cookie_content_security(policy)
.session_length(SessionLength::Predetermined { .session_lifecycle(
max_session_length: Some(time::Duration::seconds(100)), PersistentSession::default()
}) .session_ttl(time::Duration::seconds(100)),
)
.build(), .build(),
) )
.service(web::resource("/").to(|ses: Session| async move { .service(web::resource("/").to(|ses: Session| async move {
@ -246,12 +246,7 @@ pub mod test_helpers {
let request = test::TestRequest::get().to_request(); let request = test::TestRequest::get().to_request();
let response = app.call(request).await.unwrap(); let response = app.call(request).await.unwrap();
let cookie = response let cookie = response.get_cookie("actix-test").unwrap().clone();
.response()
.cookies()
.find(|c| c.name() == "actix-test")
.unwrap()
.clone();
assert_eq!(cookie.path().unwrap(), "/test/"); assert_eq!(cookie.path().unwrap(), "/test/");
let request = test::TestRequest::with_uri("/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")); 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<F, Store>( pub(super) async fn expiration_is_refreshed_on_changes<F, Store>(
store_builder: F, store_builder: F,
policy: CookieContentSecurity, policy: CookieContentSecurity,
@ -268,14 +312,15 @@ pub mod test_helpers {
Store: SessionStore + 'static, Store: SessionStore + 'static,
F: Fn() -> Store + Clone + Send + 'static, F: Fn() -> Store + Clone + Send + 'static,
{ {
let session_ttl = time::Duration::seconds(60);
let app = test::init_service( let app = test::init_service(
App::new() App::new()
.wrap( .wrap(
SessionMiddleware::builder(store_builder(), key()) SessionMiddleware::builder(store_builder(), key())
.cookie_content_security(policy) .cookie_content_security(policy)
.session_length(SessionLength::Predetermined { .session_lifecycle(
max_session_length: Some(time::Duration::seconds(60)), PersistentSession::default().session_ttl(session_ttl),
}) )
.build(), .build(),
) )
.service(web::resource("/").to(|ses: Session| async move { .service(web::resource("/").to(|ses: Session| async move {
@ -288,25 +333,19 @@ pub mod test_helpers {
let request = test::TestRequest::get().to_request(); let request = test::TestRequest::get().to_request();
let response = app.call(request).await.unwrap(); let response = app.call(request).await.unwrap();
let cookie_1 = response let cookie_1 = response.get_cookie("id").expect("Cookie is set");
.response() assert_eq!(cookie_1.max_age(), Some(session_ttl));
.cookies()
.find(|c| c.name() == "id")
.expect("Cookie is set");
assert_eq!(cookie_1.max_age(), Some(Duration::seconds(60)));
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(); let response = app.call(request).await.unwrap();
assert!(response.response().cookies().next().is_none()); 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 response = app.call(request).await.unwrap();
let cookie_2 = response let cookie_2 = response.get_cookie("id").expect("Cookie is set");
.response() assert_eq!(cookie_2.max_age(), Some(session_ttl));
.cookies()
.find(|c| c.name() == "id")
.expect("Cookie is set");
assert_eq!(cookie_2.max_age(), Some(Duration::seconds(60)));
} }
pub(super) async fn guard<F, Store>(store_builder: F, policy: CookieContentSecurity) pub(super) async fn guard<F, Store>(store_builder: F, policy: CookieContentSecurity)
@ -320,9 +359,9 @@ pub mod test_helpers {
SessionMiddleware::builder(store_builder(), key()) SessionMiddleware::builder(store_builder(), key())
.cookie_name("test-session".into()) .cookie_name("test-session".into())
.cookie_content_security(policy) .cookie_content_security(policy)
.session_length(SessionLength::Predetermined { .session_lifecycle(
max_session_length: Some(time::Duration::days(7)), PersistentSession::default().session_ttl(time::Duration::days(7)),
}) )
.build(), .build(),
) )
.wrap(middleware::Logger::default()) .wrap(middleware::Logger::default())
@ -402,15 +441,16 @@ pub mod test_helpers {
Store: SessionStore + 'static, Store: SessionStore + 'static,
F: Fn() -> Store + Clone + Send + 'static, F: Fn() -> Store + Clone + Send + 'static,
{ {
let session_ttl = time::Duration::days(7);
let srv = actix_test::start(move || { let srv = actix_test::start(move || {
App::new() App::new()
.wrap( .wrap(
SessionMiddleware::builder(store_builder(), key()) SessionMiddleware::builder(store_builder(), key())
.cookie_name("test-session".into()) .cookie_name("test-session".into())
.cookie_content_security(policy) .cookie_content_security(policy)
.session_length(SessionLength::Predetermined { .session_lifecycle(
max_session_length: Some(time::Duration::days(7)), PersistentSession::default().session_ttl(session_ttl),
}) )
.build(), .build(),
) )
.wrap(middleware::Logger::default()) .wrap(middleware::Logger::default())
@ -456,7 +496,7 @@ pub mod test_helpers {
.into_iter() .into_iter()
.find(|c| c.name() == "test-session") .find(|c| c.name() == "test-session")
.unwrap(); .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 // Step 3: GET index, including session cookie #1 in request
// - set-cookie will *not* be in response // - set-cookie will *not* be in response
@ -494,7 +534,7 @@ pub mod test_helpers {
.into_iter() .into_iter()
.find(|c| c.name() == "test-session") .find(|c| c.name() == "test-session")
.unwrap(); .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 // Step 5: POST to login, including session cookie #2 in request
// - set-cookie actix-session will be in response (session cookie #3) // - set-cookie actix-session will be in response (session cookie #3)
@ -675,5 +715,18 @@ pub mod test_helpers {
Ok(HttpResponse::Ok().body(body)) Ok(HttpResponse::Ok().body(body))
} }
trait ServiceResponseExt {
fn get_cookie(&self, cookie_name: &str) -> Option<actix_web::cookie::Cookie<'_>>;
}
impl ServiceResponseExt for ServiceResponse {
fn get_cookie(&self, cookie_name: &str) -> Option<actix_web::cookie::Cookie<'_>> {
self.response()
.cookies()
.into_iter()
.find(|c| c.name() == cookie_name)
}
}
} }
} }

View File

@ -3,15 +3,18 @@ use std::{collections::HashMap, convert::TryInto, fmt, future::Future, pin::Pin,
use actix_utils::future::{ready, Ready}; use actix_utils::future::{ready, Ready};
use actix_web::{ use actix_web::{
body::MessageBody, body::MessageBody,
cookie::{Cookie, CookieJar, Key, SameSite}, cookie::{Cookie, CookieJar, Key},
dev::{forward_ready, ResponseHead, Service, ServiceRequest, ServiceResponse, Transform}, dev::{forward_ready, ResponseHead, Service, ServiceRequest, ServiceResponse, Transform},
http::header::{HeaderValue, SET_COOKIE}, http::header::{HeaderValue, SET_COOKIE},
HttpResponse, HttpResponse,
}; };
use anyhow::Context; use anyhow::Context;
use time::Duration;
use crate::{ use crate::{
config::{
self, Configuration, CookieConfiguration, CookieContentSecurity, SessionMiddlewareBuilder,
TtlExtensionPolicy,
},
storage::{LoadError, SessionKey, SessionStore}, storage::{LoadError, SessionKey, SessionStore},
Session, SessionStatus, Session, SessionStatus,
}; };
@ -66,8 +69,9 @@ use crate::{
/// If you want to customise use [`builder`](Self::builder) instead of [`new`](Self::new): /// If you want to customise use [`builder`](Self::builder) instead of [`new`](Self::new):
/// ///
/// ```no_run /// ```no_run
/// use actix_web::{cookie::Key, web, App, HttpServer, HttpResponse, Error}; /// use actix_web::{App, cookie::{Key, time}, Error, HttpResponse, HttpServer, web};
/// use actix_session::{Session, SessionMiddleware, storage::RedisActorSessionStore, SessionLength}; /// 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. /// // The secret key would usually be read from a configuration file/environment variables.
/// fn get_secret_key() -> Key { /// fn get_secret_key() -> Key {
@ -87,9 +91,10 @@ use crate::{
/// RedisActorSessionStore::new(redis_connection_string), /// RedisActorSessionStore::new(redis_connection_string),
/// secret_key.clone() /// secret_key.clone()
/// ) /// )
/// .session_length(SessionLength::Predetermined { /// .session_lifecycle(
/// max_session_length: Some(time::Duration::days(5)), /// PersistentSession::default()
/// }) /// .session_ttl(time::Duration::days(5))
/// )
/// .build(), /// .build(),
/// ) /// )
/// .default_service(web::to(|| HttpResponse::Ok()))) /// .default_service(web::to(|| HttpResponse::Ok())))
@ -114,117 +119,6 @@ pub struct SessionMiddleware<Store: SessionStore> {
configuration: Rc<Configuration>, configuration: Rc<Configuration>,
} }
#[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<String>,
max_age: Option<Duration>,
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<Duration>,
},
/// 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<Duration>,
},
}
/// 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<Store: SessionStore> SessionMiddleware<Store> { impl<Store: SessionStore> SessionMiddleware<Store> {
/// Use [`SessionMiddleware::new`] to initialize the session framework using the default /// Use [`SessionMiddleware::new`] to initialize the session framework using the default
/// parameters. /// parameters.
@ -234,10 +128,7 @@ impl<Store: SessionStore> SessionMiddleware<Store> {
/// [`SessionStore]); /// [`SessionStore]);
/// - a secret key, to sign or encrypt the content of client-side session cookie. /// - a secret key, to sign or encrypt the content of client-side session cookie.
pub fn new(store: Store, key: Key) -> Self { pub fn new(store: Store, key: Key) -> Self {
Self { Self::builder(store, key).build()
storage_backend: Rc::new(store),
configuration: Rc::new(default_configuration(key)),
}
} }
/// A fluent API to configure [`SessionMiddleware`]. /// A fluent API to configure [`SessionMiddleware`].
@ -247,124 +138,13 @@ impl<Store: SessionStore> SessionMiddleware<Store> {
/// [`SessionStore]); /// [`SessionStore]);
/// - a secret key, to sign or encrypt the content of client-side session cookie. /// - a secret key, to sign or encrypt the content of client-side session cookie.
pub fn builder(store: Store, key: Key) -> SessionMiddlewareBuilder<Store> { pub fn builder(store: Store, key: Key) -> SessionMiddlewareBuilder<Store> {
SessionMiddlewareBuilder { SessionMiddlewareBuilder::new(store, config::default_configuration(key))
}
pub(crate) fn from_parts(store: Store, configuration: Configuration) -> Self {
Self {
storage_backend: Rc::new(store), storage_backend: Rc::new(store),
configuration: default_configuration(key), configuration: Rc::new(configuration),
}
}
}
/// A fluent builder to construct a [`SessionMiddleware`] instance with custom configuration
/// parameters.
#[must_use]
pub struct SessionMiddlewareBuilder<Store: SessionStore> {
storage_backend: Rc<Store>,
configuration: Configuration,
}
impl<Store: SessionStore> SessionMiddlewareBuilder<Store> {
/// 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<String>) -> 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<Store> {
SessionMiddleware {
storage_backend: self.storage_backend,
configuration: Rc::new(self.configuration),
} }
} }
} }
@ -509,16 +289,39 @@ where
} }
SessionStatus::Unchanged => { 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) 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<SessionKey> { fn extract_session_key(req: &ServiceRequest, config: &CookieConfiguration) -> Option<SessionKey> {
let cookies = req.cookies().ok()?; let cookies = req.cookies().ok()?;
let session_cookie = cookies let session_cookie = cookies

View File

@ -1,16 +1,20 @@
use std::{ use std::{
cell::{Ref, RefCell}, cell::{Ref, RefCell},
collections::HashMap, collections::HashMap,
error::Error as StdError,
mem, mem,
rc::Rc, rc::Rc,
}; };
use actix_utils::future::{ready, Ready}; use actix_utils::future::{ready, Ready};
use actix_web::{ use actix_web::{
body::BoxBody,
dev::{Extensions, Payload, ServiceRequest, ServiceResponse}, dev::{Extensions, Payload, ServiceRequest, ServiceResponse},
error::Error, error::Error,
FromRequest, HttpMessage, HttpRequest, FromRequest, HttpMessage, HttpRequest, HttpResponse, ResponseError,
}; };
use anyhow::Context;
use derive_more::{Display, From};
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
/// The primary interface to access and modify session state. /// The primary interface to access and modify session state.
@ -38,6 +42,7 @@ use serde::{de::DeserializeOwned, Serialize};
/// [`SessionExt`]. /// [`SessionExt`].
/// ///
/// [`SessionExt`]: crate::SessionExt /// [`SessionExt`]: crate::SessionExt
#[derive(Clone)]
pub struct Session(Rc<RefCell<SessionInner>>); pub struct Session(Rc<RefCell<SessionInner>>);
/// Status of a [`Session`]. /// Status of a [`Session`].
@ -89,9 +94,20 @@ impl Session {
/// Get a `value` from the session. /// Get a `value` from the session.
/// ///
/// It returns an error if it fails to deserialize as `T` the JSON value associated with `key`. /// It returns an error if it fails to deserialize as `T` the JSON value associated with `key`.
pub fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, serde_json::Error> { pub fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, SessionGetError> {
if let Some(val_str) = self.0.borrow().state.get(key) { 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::<T>()
)
})
.map_err(SessionGetError)?,
))
} else { } else {
Ok(None) Ok(None)
} }
@ -115,17 +131,29 @@ impl Session {
/// only a reference to the value is taken. /// only a reference to the value is taken.
/// ///
/// It returns an error if it fails to serialize `value` to JSON. /// It returns an error if it fails to serialize `value` to JSON.
pub fn insert( pub fn insert<T: Serialize>(
&self, &self,
key: impl Into<String>, key: impl Into<String>,
value: impl Serialize, value: T,
) -> Result<(), serde_json::Error> { ) -> Result<(), SessionInsertError> {
let mut inner = self.0.borrow_mut(); let mut inner = self.0.borrow_mut();
if inner.status != SessionStatus::Purged { if inner.status != SessionStatus::Purged {
inner.status = SessionStatus::Changed; 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::<T>(),
&key
)
})
.map_err(SessionInsertError)?;
inner.state.insert(key, val);
} }
Ok(()) Ok(())
@ -147,7 +175,7 @@ impl Session {
/// Remove value from the session and deserialize. /// 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. /// otherwise returns un-deserialized JSON string.
pub fn remove_as<T: DeserializeOwned>(&self, key: &str) -> Option<Result<T, String>> { pub fn remove_as<T: DeserializeOwned>(&self, key: &str) -> Option<Result<T, String>> {
self.remove(key) self.remove(key)
@ -155,7 +183,7 @@ impl Session {
Ok(val) => Ok(val), Ok(val) => Ok(val),
Err(_err) => { Err(_err) => {
tracing::debug!( tracing::debug!(
"removed value (key: {}) could not be deserialized as {}", "Removed value (key: {}) could not be deserialized as {}",
key, key,
std::any::type_name::<T>() std::any::type_name::<T>()
); );
@ -206,9 +234,9 @@ impl Session {
/// Returns session status and iterator of key-value pairs of changes. /// 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, /// This is a destructive operation - the session state is removed from the request extensions
/// leaving behind a new empty map. It should only be used when the session is being finalised (i.e. /// typemap, leaving behind a new empty map. It should only be used when the session is being
/// in `SessionMiddleware`). /// finalised (i.e. in `SessionMiddleware`).
pub(crate) fn get_changes<B>( pub(crate) fn get_changes<B>(
res: &mut ServiceResponse<B>, res: &mut ServiceResponse<B>,
) -> (SessionStatus, HashMap<String, String>) { ) -> (SessionStatus, HashMap<String, String>) {
@ -265,3 +293,37 @@ impl FromRequest for Session {
ready(Ok(Session::get_session(&mut *req.extensions_mut()))) 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<BoxBody> {
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<BoxBody> {
HttpResponse::new(self.status_code())
}
}

View File

@ -1,6 +1,7 @@
use std::convert::TryInto; use std::convert::TryInto;
use time::Duration; use actix_web::cookie::time::Duration;
use anyhow::Error;
use super::SessionKey; use super::SessionKey;
use crate::storage::{ use crate::storage::{
@ -34,9 +35,9 @@ use crate::storage::{
/// ``` /// ```
/// ///
/// # Limitations /// # Limitations
/// Cookies are subject to size limits - we require session keys to be shorter than 4096 bytes. This /// Cookies are subject to size limits so we require session keys to be shorter than 4096 bytes.
/// translates into a limit on the maximum size of the session state when using cookies as storage /// This translates into a limit on the maximum size of the session state when using cookies as
/// backend. /// storage backend.
/// ///
/// The session cookie can always be inspected by end users via the developer tools exposed by their /// 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 /// 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 /// There is no way to invalidate a session before its natural expiry when using cookies as the
/// storage backend. /// storage backend.
/// ///
/// [`CookieContentSecurity::Private`]: crate::CookieContentSecurity::Private /// [`CookieContentSecurity::Private`]: crate::config::CookieContentSecurity::Private
#[cfg_attr(docsrs, doc(cfg(feature = "cookie-session")))] #[cfg_attr(docsrs, doc(cfg(feature = "cookie-session")))]
#[derive(Default)] #[derive(Default)]
#[non_exhaustive] #[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> { async fn delete(&self, _session_key: &SessionKey) -> Result<(), anyhow::Error> {
Ok(()) Ok(())
} }

View File

@ -1,7 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use actix_web::cookie::time::Duration;
use derive_more::Display; use derive_more::Display;
use time::Duration;
use super::SessionKey; use super::SessionKey;
@ -36,6 +36,13 @@ pub trait SessionStore {
ttl: &Duration, ttl: &Duration,
) -> Result<SessionKey, UpdateError>; ) -> Result<SessionKey, UpdateError>;
/// 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. /// Deletes a session from the store.
async fn delete(&self, session_key: &SessionKey) -> Result<(), anyhow::Error>; async fn delete(&self, session_key: &SessionKey) -> Result<(), anyhow::Error>;
} }

View File

@ -1,6 +1,7 @@
use actix::Addr; use actix::Addr;
use actix_redis::{resp_array, Command, RedisActor, RespValue}; 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 super::SessionKey;
use crate::storage::{ 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> { async fn delete(&self, session_key: &SessionKey) -> Result<(), anyhow::Error> {
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
@ -258,9 +277,11 @@ impl SessionStore for RedisActorSessionStore {
} }
#[cfg(test)] #[cfg(test)]
mod test { mod tests {
use std::collections::HashMap; use std::collections::HashMap;
use actix_web::cookie::time::Duration;
use super::*; use super::*;
use crate::test_helpers::acceptance_test_suite; use crate::test_helpers::acceptance_test_suite;
@ -286,7 +307,7 @@ mod test {
let session_key = generate_session_key(); let session_key = generate_session_key();
let initial_session_key = session_key.as_ref().to_owned(); let initial_session_key = session_key.as_ref().to_owned();
let updated_session_key = store let updated_session_key = store
.update(session_key, HashMap::new(), &time::Duration::seconds(1)) .update(session_key, HashMap::new(), &Duration::seconds(1))
.await .await
.unwrap(); .unwrap();
assert_ne!(initial_session_key, updated_session_key.as_ref()); assert_ne!(initial_session_key, updated_session_key.as_ref());

View File

@ -1,7 +1,8 @@
use std::sync::Arc; use std::{convert::TryInto, sync::Arc};
use redis::{aio::ConnectionManager, Cmd, FromRedisValue, RedisResult, Value}; use actix_web::cookie::time::Duration;
use time::{self, Duration}; use anyhow::{Context, Error};
use redis::{aio::ConnectionManager, AsyncCommands, Cmd, FromRedisValue, RedisResult, Value};
use super::SessionKey; use super::SessionKey;
use crate::storage::{ use crate::storage::{
@ -28,6 +29,7 @@ use crate::storage::{
/// let secret_key = get_secret_key(); /// let secret_key = get_secret_key();
/// let redis_connection_string = "redis://127.0.0.1:6379"; /// let redis_connection_string = "redis://127.0.0.1:6379";
/// let store = RedisSessionStore::new(redis_connection_string).await.unwrap(); /// let store = RedisSessionStore::new(redis_connection_string).await.unwrap();
///
/// HttpServer::new(move || /// HttpServer::new(move ||
/// App::new() /// App::new()
/// .wrap(SessionMiddleware::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> { async fn delete(&self, session_key: &SessionKey) -> Result<(), anyhow::Error> {
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
self.execute_command(redis::cmd("DEL").arg(&[&cache_key])) self.execute_command(redis::cmd("DEL").arg(&[&cache_key]))
@ -272,9 +289,10 @@ impl RedisSessionStore {
} }
#[cfg(test)] #[cfg(test)]
mod test { mod tests {
use std::collections::HashMap; use std::collections::HashMap;
use actix_web::cookie::time;
use redis::AsyncCommands; use redis::AsyncCommands;
use super::*; use super::*;

View File

@ -2,16 +2,15 @@ use std::convert::TryFrom;
use derive_more::{Display, From}; use derive_more::{Display, From};
/// A session key, the string stored in a client-side cookie to associate a user /// A session key, the string stored in a client-side cookie to associate a user with its session
/// with its session state on the backend. /// state on the backend.
/// ///
/// ## Validation /// # Validation
/// /// Session keys are stored as cookies, therefore they cannot be arbitrary long. Session keys are
/// Session keys are stored as cookies, therefore they cannot be arbitrary long. /// required to be smaller than 4064 bytes.
/// We require session keys to be smaller than 4064 bytes.
/// ///
/// ```rust /// ```rust
/// use std::convert::TryInto; /// # use std::convert::TryInto;
/// use actix_session::storage::SessionKey; /// use actix_session::storage::SessionKey;
/// ///
/// let key: String = std::iter::repeat('a').take(4065).collect(); /// let key: String = std::iter::repeat('a').take(4065).collect();
@ -24,15 +23,15 @@ pub struct SessionKey(String);
impl TryFrom<String> for SessionKey { impl TryFrom<String> for SessionKey {
type Error = InvalidSessionKeyError; type Error = InvalidSessionKeyError;
fn try_from(v: String) -> Result<Self, Self::Error> { fn try_from(val: String) -> Result<Self, Self::Error> {
if v.len() > 4064 { if val.len() > 4064 {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"The session key is bigger than 4064 bytes, the upper limit on cookie content." "The session key is bigger than 4064 bytes, the upper limit on cookie content."
) )
.into()); .into());
} }
Ok(SessionKey(v)) Ok(SessionKey(val))
} }
} }
@ -43,8 +42,8 @@ impl AsRef<str> for SessionKey {
} }
impl From<SessionKey> for String { impl From<SessionKey> for String {
fn from(k: SessionKey) -> Self { fn from(key: SessionKey) -> Self {
k.0 key.0
} }
} }

View File

@ -71,6 +71,10 @@ impl SessionStore for MockStore {
todo!() todo!()
} }
async fn update_ttl(&self, _session_key: &SessionKey, _ttl: &Duration) -> Result<(), Error> {
todo!()
}
async fn delete(&self, _session_key: &SessionKey) -> Result<(), Error> { async fn delete(&self, _session_key: &SessionKey) -> Result<(), Error> {
todo!() todo!()
} }

View File

@ -1,6 +1,13 @@
# Changes # 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 ## 0.6.0 - 2022-03-01

View File

@ -1,6 +1,6 @@
[package] [package]
name = "actix-web-httpauth" name = "actix-web-httpauth"
version = "0.6.0" version = "0.7.0"
authors = [ authors = [
"svartalf <self@svartalf.info>", "svartalf <self@svartalf.info>",
"Yuki Okushi <huyuumi.dev@gmail.com>", "Yuki Okushi <huyuumi.dev@gmail.com>",

View File

@ -3,14 +3,14 @@
> HTTP authentication schemes for [actix-web](https://github.com/actix/actix-web). > 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) [![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) ![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 ## Documentation & Resources
- [API Documentation](https://docs.rs/actix-web-httpauth/) - [API Documentation](https://docs.rs/actix-web-httpauth/)
- Minimum Supported Rust Version (MSRV): 1.54 - Minimum Supported Rust Version (MSRV): 1.57
## Features ## Features
- Typed [Authorization] and [WWW-Authenticate] headers - Typed [Authorization] and [WWW-Authenticate] headers

View File

@ -3,7 +3,10 @@ use actix_web::{middleware, web, App, Error, HttpServer};
use actix_web_httpauth::extractors::basic::BasicAuth; use actix_web_httpauth::extractors::basic::BasicAuth;
use actix_web_httpauth::middleware::HttpAuthentication; use actix_web_httpauth::middleware::HttpAuthentication;
async fn validator(req: ServiceRequest, _credentials: BasicAuth) -> Result<ServiceRequest, Error> { async fn validator(
req: ServiceRequest,
_credentials: BasicAuth,
) -> Result<ServiceRequest, (Error, ServiceRequest)> {
Ok(req) Ok(req)
} }

View File

@ -5,7 +5,7 @@ use actix_web_httpauth::{extractors::bearer::BearerAuth, middleware::HttpAuthent
async fn ok_validator( async fn ok_validator(
req: ServiceRequest, req: ServiceRequest,
credentials: BearerAuth, credentials: BearerAuth,
) -> Result<ServiceRequest, Error> { ) -> Result<ServiceRequest, (Error, ServiceRequest)> {
eprintln!("{:?}", credentials); eprintln!("{:?}", credentials);
Ok(req) Ok(req)
} }

View File

@ -39,7 +39,7 @@ impl<T, F, O> HttpAuthentication<T, F>
where where
T: AuthExtractor, T: AuthExtractor,
F: Fn(ServiceRequest, T) -> O, F: Fn(ServiceRequest, T) -> O,
O: Future<Output = Result<ServiceRequest, Error>>, O: Future<Output = Result<ServiceRequest, (Error, ServiceRequest)>>,
{ {
/// Construct `HttpAuthentication` middleware with the provided auth extractor `T` and /// Construct `HttpAuthentication` middleware with the provided auth extractor `T` and
/// validation callback `F`. /// validation callback `F`.
@ -54,7 +54,7 @@ where
impl<F, O> HttpAuthentication<basic::BasicAuth, F> impl<F, O> HttpAuthentication<basic::BasicAuth, F>
where where
F: Fn(ServiceRequest, basic::BasicAuth) -> O, F: Fn(ServiceRequest, basic::BasicAuth) -> O,
O: Future<Output = Result<ServiceRequest, Error>>, O: Future<Output = Result<ServiceRequest, (Error, ServiceRequest)>>,
{ {
/// Construct `HttpAuthentication` middleware for the HTTP "Basic" authentication scheme. /// Construct `HttpAuthentication` middleware for the HTTP "Basic" authentication scheme.
/// ///
@ -70,7 +70,7 @@ where
/// async fn validator( /// async fn validator(
/// req: ServiceRequest, /// req: ServiceRequest,
/// credentials: BasicAuth, /// credentials: BasicAuth,
/// ) -> Result<ServiceRequest, Error> { /// ) -> Result<ServiceRequest, (Error, ServiceRequest)> {
/// // All users are great and more than welcome! /// // All users are great and more than welcome!
/// Ok(req) /// Ok(req)
/// } /// }
@ -85,7 +85,7 @@ where
impl<F, O> HttpAuthentication<bearer::BearerAuth, F> impl<F, O> HttpAuthentication<bearer::BearerAuth, F>
where where
F: Fn(ServiceRequest, bearer::BearerAuth) -> O, F: Fn(ServiceRequest, bearer::BearerAuth) -> O,
O: Future<Output = Result<ServiceRequest, Error>>, O: Future<Output = Result<ServiceRequest, (Error, ServiceRequest)>>,
{ {
/// Construct `HttpAuthentication` middleware for the HTTP "Bearer" authentication scheme. /// Construct `HttpAuthentication` middleware for the HTTP "Bearer" authentication scheme.
/// ///
@ -96,7 +96,7 @@ where
/// # use actix_web_httpauth::middleware::HttpAuthentication; /// # use actix_web_httpauth::middleware::HttpAuthentication;
/// # use actix_web_httpauth::extractors::bearer::{Config, BearerAuth}; /// # use actix_web_httpauth::extractors::bearer::{Config, BearerAuth};
/// # use actix_web_httpauth::extractors::{AuthenticationError, AuthExtractorConfig}; /// # use actix_web_httpauth::extractors::{AuthenticationError, AuthExtractorConfig};
/// async fn validator(req: ServiceRequest, credentials: BearerAuth) -> Result<ServiceRequest, Error> { /// async fn validator(req: ServiceRequest, credentials: BearerAuth) -> Result<ServiceRequest, (Error, ServiceRequest)> {
/// if credentials.token() == "mF_9.B5f-4.1JqM" { /// if credentials.token() == "mF_9.B5f-4.1JqM" {
/// Ok(req) /// Ok(req)
/// } else { /// } else {
@ -105,7 +105,7 @@ where
/// .unwrap_or_else(Default::default) /// .unwrap_or_else(Default::default)
/// .scope("urn:example:channel=HBO&urn:example:rating=G,PG-13"); /// .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<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static, S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static, S::Future: 'static,
F: Fn(ServiceRequest, T) -> O + 'static, F: Fn(ServiceRequest, T) -> O + 'static,
O: Future<Output = Result<ServiceRequest, Error>> + 'static, O: Future<Output = Result<ServiceRequest, (Error, ServiceRequest)>> + 'static,
T: AuthExtractor + 'static, T: AuthExtractor + 'static,
B: MessageBody + 'static, B: MessageBody + 'static,
{ {
@ -155,7 +155,7 @@ where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static, S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static, S::Future: 'static,
F: Fn(ServiceRequest, T) -> O + 'static, F: Fn(ServiceRequest, T) -> O + 'static,
O: Future<Output = Result<ServiceRequest, Error>> + 'static, O: Future<Output = Result<ServiceRequest, (Error, ServiceRequest)>> + 'static,
T: AuthExtractor + 'static, T: AuthExtractor + 'static,
B: MessageBody + 'static, B: MessageBody + 'static,
{ {
@ -178,9 +178,12 @@ where
} }
}; };
// TODO: alter to remove ? operator; an error response is required for downstream let req = match process_fn(req, credentials).await {
// middleware to do their thing (eg. cors adding headers) Ok(req) => req,
let req = process_fn(req, credentials).await?; Err((err, req)) => {
return Ok(req.error_response(err).map_into_right_body());
}
};
service.call(req).await.map(|res| res.map_into_left_body()) service.call(req).await.map(|res| res.map_into_left_body())
} }
@ -362,10 +365,10 @@ mod tests {
#[actix_web::test] #[actix_web::test]
async fn test_middleware_works_with_app() { async fn test_middleware_works_with_app() {
async fn validator( async fn validator(
_req: ServiceRequest, req: ServiceRequest,
_credentials: BasicAuth, _credentials: BasicAuth,
) -> Result<ServiceRequest, actix_web::Error> { ) -> Result<ServiceRequest, (actix_web::Error, ServiceRequest)> {
Err(ErrorForbidden("You are not welcome!")) Err((ErrorForbidden("You are not welcome!"), req))
} }
let middleware = HttpAuthentication::basic(validator); let middleware = HttpAuthentication::basic(validator);
@ -387,10 +390,10 @@ mod tests {
#[actix_web::test] #[actix_web::test]
async fn test_middleware_works_with_scope() { async fn test_middleware_works_with_scope() {
async fn validator( async fn validator(
_req: ServiceRequest, req: ServiceRequest,
_credentials: BasicAuth, _credentials: BasicAuth,
) -> Result<ServiceRequest, actix_web::Error> { ) -> Result<ServiceRequest, (actix_web::Error, ServiceRequest)> {
Err(ErrorForbidden("You are not welcome!")) Err((ErrorForbidden("You are not welcome!"), req))
} }
let middleware = actix_web::middleware::Compat::new(HttpAuthentication::basic(validator)); let middleware = actix_web::middleware::Compat::new(HttpAuthentication::basic(validator));

View File

@ -1 +1 @@
msrv = "1.54.0" msrv = "1.57"