1
0
mirror of https://github.com/actix/actix-extras.git synced 2025-04-22 18:04:52 +02:00

Compare commits

..

No commits in common. "master" and "httpauth-v0.6.0" have entirely different histories.

159 changed files with 4779 additions and 60933 deletions

View File

@ -1,7 +1,7 @@
[alias]
lint = "clippy --workspace --tests --examples --bins -- -Dclippy::todo"
ci-min = "hack check --workspace --no-default-features"
ci-check-min-examples = "hack check --workspace --no-default-features --examples"
ci-check-min-tests = "hack check --workspace --no-default-features --tests --examples"
ci-check = "check --workspace --tests --examples --bins"
ci-test = "test --workspace --lib --tests --all-features --examples --bins --no-fail-fast"
ci-doctest = "test --workspace --doc --all-features --no-fail-fast"

View File

@ -3,41 +3,35 @@ name: bug report
about: create a bug report
---
Your issue may already be reported! Please search on the [actix-extras issue tracker](https://github.com/actix/actix-extras/issues) before creating one.
Your issue may already be reported!
Please search on the [actix-extras issue tracker](https://github.com/actix/actix-extras/issues) before creating one.
## Expected Behavior
<!--- If you're describing a bug, tell us what should happen -->
<!--- If you're suggesting a change/improvement, tell us how it should work -->
## Current Behavior
<!--- If describing a bug, tell us what happens instead of the expected behavior -->
<!--- If suggesting a change/improvement, explain the difference from current behavior -->
## Possible Solution
<!--- Not obligatory, but suggest a fix/reason for the bug, -->
<!--- or ideas how to implement the addition or change -->
## Steps to Reproduce (for bugs)
<!--- Provide a link to a live example, or an unambiguous set of steps to -->
<!--- reproduce this bug. Include code to reproduce, if relevant -->
1.
2.
3.
4.
## Context
<!--- How has this issue affected you? What are you trying to accomplish? -->
<!--- Providing context helps us come up with a solution that is most useful in the real world -->
## Your Environment
<!--- Include as many relevant details about the environment you experienced the bug in -->
- Rust version (output of `rustc -V`):
- `actix-*` crate versions:
* Rust Version (I.e, output of `rustc -V`):
* Actix-* crate(s) Version:

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Gitter channel (actix)
url: https://gitter.im/actix/actix
about: Please ask and answer questions about the actix project here.

View File

@ -2,26 +2,25 @@
<!-- Please fill out the following to make our reviews easy. -->
## PR Type
<!-- What kind of change does this PR make? -->
<!-- Bug Fix / Feature / Refactor / Code Style / Other -->
INSERT_PR_TYPE
## PR Checklist
## PR Checklist
<!-- Check your PR fulfills the following items. -->
<!-- For draft PRs check the boxes as you complete them. -->
- [ ] Tests for the changes have been added / updated.
- [ ] Documentation comments have been added / updated.
- [ ] A changelog entry has been made for the appropriate packages.
- [ ] Format code with the nightly rustfmt (`cargo +nightly fmt`).
- [ ] Format code with the latest stable rustfmt.
## Overview
<!-- Describe the current and new behavior. -->
<!-- Emphasize any breaking changes. -->
<!-- If this PR fixes or closes an issue, reference it here. -->
<!-- Closes #000 -->

View File

@ -1,10 +0,0 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
- package-ecosystem: cargo
directory: /
schedule:
interval: weekly

130
.github/workflows/ci-master.yml vendored Normal file
View File

@ -0,0 +1,130 @@
name: CI (master only)
on:
push:
branches: [master]
jobs:
build_and_test_linux_nightly:
strategy:
fail-fast: false
matrix:
target:
- { name: Linux, os: ubuntu-latest, triple: x86_64-unknown-linux-gnu }
version:
- nightly
name: ${{ matrix.target.name }} / ${{ matrix.version }}
runs-on: ${{ matrix.target.os }}
services:
redis:
image: redis:5.0.7
ports:
- 6379:6379
options: --entrypoint redis-server
steps:
- uses: actions/checkout@v2
- name: Install ${{ matrix.version }}
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ matrix.version }}-${{ matrix.target.triple }}
profile: minimal
override: true
- name: Generate Cargo.lock
uses: actions-rs/cargo@v1
with:
command: generate-lockfile
- name: Cache Dependencies
uses: Swatinem/rust-cache@v1.2.0
- name: Install cargo-hack
uses: actions-rs/cargo@v1
with:
command: install
args: cargo-hack
- name: check minimal
uses: actions-rs/cargo@v1
with: { command: ci-min }
- name: check minimal + tests
uses: actions-rs/cargo@v1
with: { command: ci-check-min-tests }
- name: check default
uses: actions-rs/cargo@v1
with: { command: ci-check }
- name: tests
uses: actions-rs/cargo@v1
timeout-minutes: 40
with: { command: ci-test }
- name: Clear the cargo caches
run: |
cargo install cargo-cache --version 0.6.2 --no-default-features --features ci-autoclean
cargo-cache
build_and_test_other_nightly:
strategy:
fail-fast: false
matrix:
target:
- { name: macOS, os: macos-latest, triple: x86_64-apple-darwin }
- { name: Windows, os: windows-latest, triple: x86_64-pc-windows-msvc }
version:
- nightly
name: ${{ matrix.target.name }} / ${{ matrix.version }}
runs-on: ${{ matrix.target.os }}
steps:
- uses: actions/checkout@v2
- name: Install ${{ matrix.version }}
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ matrix.version }}-${{ matrix.target.triple }}
profile: minimal
override: true
- name: Generate Cargo.lock
uses: actions-rs/cargo@v1
with:
command: generate-lockfile
- name: Cache Dependencies
uses: Swatinem/rust-cache@v1.2.0
- name: Install cargo-hack
uses: actions-rs/cargo@v1
with:
command: install
args: cargo-hack
- name: check minimal
uses: actions-rs/cargo@v1
with: { command: ci-min }
- name: check minimal + tests
uses: actions-rs/cargo@v1
with: { command: ci-check-min-tests }
- name: check default
uses: actions-rs/cargo@v1
with: { command: ci-check }
- name: tests
uses: actions-rs/cargo@v1
timeout-minutes: 40
with:
command: ci-test
args: --exclude=actix-redis -- --nocapture
- name: Clear the cargo caches
run: |
cargo install cargo-cache --version 0.6.2 --no-default-features --features ci-autoclean
cargo-cache

View File

@ -1,107 +0,0 @@
name: CI (post-merge)
on:
push: { branches: [master] }
permissions: { contents: read }
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build_and_test_linux_nightly:
strategy:
fail-fast: false
matrix:
target:
- { name: Linux, os: ubuntu-latest, triple: x86_64-unknown-linux-gnu }
name: ${{ matrix.target.name }} / nightly
runs-on: ${{ matrix.target.os }}
services:
redis:
image: redis:5.0.7
ports:
- 6379:6379
options: --entrypoint redis-server
steps:
- uses: actions/checkout@v4
- name: Install Rust (nightly)
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
with:
toolchain: nightly
- name: Install cargo-hack, cargo-ci-cache-clean
uses: taiki-e/install-action@v2.49.42
with:
tool: cargo-hack,cargo-ci-cache-clean
- name: check minimal
run: cargo ci-min
- name: check minimal + examples
run: cargo ci-check-min-examples
- name: check default
run: cargo ci-check
- name: tests
timeout-minutes: 40
run: cargo ci-test
- name: CI cache clean
run: cargo-ci-cache-clean
build_and_test_other_nightly:
strategy:
fail-fast: false
# prettier-ignore
matrix:
target:
- { name: macOS, os: macos-latest, triple: x86_64-apple-darwin }
- { name: Windows, os: windows-latest, triple: x86_64-pc-windows-msvc }
name: ${{ matrix.target.name }} / nightly
runs-on: ${{ matrix.target.os }}
steps:
- uses: actions/checkout@v4
- name: Install OpenSSL
if: matrix.target.os == 'windows-latest'
shell: bash
run: |
set -e
choco install openssl --version=1.1.1.2100 -y --no-progress
echo 'OPENSSL_DIR=C:\Program Files\OpenSSL' >> $GITHUB_ENV
echo "RUSTFLAGS=-C target-feature=+crt-static" >> $GITHUB_ENV
- name: Install Rust (nightly)
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
with:
toolchain: nightly
- name: Install cargo-hack and cargo-ci-cache-clean
uses: taiki-e/install-action@v2.49.42
with:
tool: cargo-hack,cargo-ci-cache-clean
- name: check minimal
run: cargo ci-min
- name: check minimal + examples
run: cargo ci-check-min-examples
- name: check default
run: cargo ci-check
- name: tests
timeout-minutes: 40
run: cargo ci-test --exclude=actix-session --exclude=actix-limitation -- --nocapture
- name: CI cache clean
run: cargo-ci-cache-clean

View File

@ -3,17 +3,9 @@ name: CI
on:
pull_request:
types: [opened, synchronize, reopened]
merge_group:
types: [checks_requested]
push:
branches: [master]
permissions: { contents: read }
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build_and_test_linux:
strategy:
@ -22,132 +14,147 @@ jobs:
target:
- { name: Linux, os: ubuntu-latest, triple: x86_64-unknown-linux-gnu }
version:
- { name: msrv, version: 1.75.0 }
- { name: stable, version: stable }
- 1.54.0 # MSRV
- stable
name: ${{ matrix.target.name }} / ${{ matrix.version.name }}
name: ${{ matrix.target.name }} / ${{ matrix.version }}
runs-on: ${{ matrix.target.os }}
services:
redis:
image: redis:6
image: redis:5.0.7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
--entrypoint redis-server
options: --entrypoint redis-server
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Install Rust (${{ matrix.version.name }})
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
- name: Install ${{ matrix.version }}
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ matrix.version.version }}
toolchain: ${{ matrix.version }}-${{ matrix.target.triple }}
profile: minimal
override: true
- name: Install cargo-hack and cargo-ci-cache-clean, just
uses: taiki-e/install-action@v2.49.42
- name: Generate Cargo.lock
uses: actions-rs/cargo@v1
with:
tool: cargo-hack,cargo-ci-cache-clean,just
command: generate-lockfile
- name: Cache Dependencies
uses: Swatinem/rust-cache@v1.2.0
- name: workaround MSRV issues
if: matrix.version.name == 'msrv'
run: just downgrade-for-msrv
- name: Install cargo-hack
uses: actions-rs/cargo@v1
with:
command: install
args: cargo-hack
- name: check minimal
run: cargo ci-min
uses: actions-rs/cargo@v1
with: { command: ci-min }
- name: check minimal + examples
run: cargo ci-check-min-examples
- name: check minimal + tests
uses: actions-rs/cargo@v1
with: { command: ci-check-min-tests }
- name: check default
run: cargo ci-check
uses: actions-rs/cargo@v1
with: { command: ci-check }
- name: tests
uses: actions-rs/cargo@v1
timeout-minutes: 40
run: cargo ci-test
with: { command: ci-test }
- name: CI cache clean
run: cargo-ci-cache-clean
- name: Clear the cargo caches
run: |
cargo install cargo-cache --version 0.6.2 --no-default-features --features ci-autoclean
cargo-cache
build_and_test_other:
strategy:
fail-fast: false
matrix:
# prettier-ignore
target:
- { name: macOS, os: macos-latest, triple: x86_64-apple-darwin }
- { name: Windows, os: windows-latest, triple: x86_64-pc-windows-msvc }
version:
- { name: msrv, version: 1.75.0 }
- { name: stable, version: stable }
- 1.54.0 # MSRV
- stable
name: ${{ matrix.target.name }} / ${{ matrix.version.name }}
name: ${{ matrix.target.name }} / ${{ matrix.version }}
runs-on: ${{ matrix.target.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Install OpenSSL
if: matrix.target.os == 'windows-latest'
shell: bash
run: |
set -e
choco install openssl --version=1.1.1.2100 -y --no-progress
echo 'OPENSSL_DIR=C:\Program Files\OpenSSL' >> $GITHUB_ENV
echo "RUSTFLAGS=-C target-feature=+crt-static" >> $GITHUB_ENV
- name: Install Rust (${{ matrix.version.name }})
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
- name: Install ${{ matrix.version }}
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ matrix.version.version }}
toolchain: ${{ matrix.version }}-${{ matrix.target.triple }}
profile: minimal
override: true
- name: Install cargo-hack, cargo-ci-cache-clean, just
uses: taiki-e/install-action@v2.49.42
- name: Generate Cargo.lock
uses: actions-rs/cargo@v1
with:
tool: cargo-hack,cargo-ci-cache-clean,just
command: generate-lockfile
- name: Cache Dependencies
uses: Swatinem/rust-cache@v1.2.0
- name: workaround MSRV issues
if: matrix.version.name == 'msrv'
run: just downgrade-for-msrv
- name: Install cargo-hack
uses: actions-rs/cargo@v1
with:
command: install
args: cargo-hack
- name: check minimal
run: cargo ci-min
uses: actions-rs/cargo@v1
with: { command: ci-min }
- name: check minimal + examples
run: cargo ci-check-min-examples
- name: check minimal + tests
uses: actions-rs/cargo@v1
with: { command: ci-check-min-tests }
- name: check default
run: cargo ci-check
uses: actions-rs/cargo@v1
with: { command: ci-check }
- name: tests
uses: actions-rs/cargo@v1
timeout-minutes: 40
run: cargo ci-test --exclude=actix-session --exclude=actix-limitation
with:
command: ci-test
args: --exclude=actix-redis -- --nocapture
- name: CI cache clean
run: cargo-ci-cache-clean
- name: Clear the cargo caches
run: |
cargo install cargo-cache --version 0.6.2 --no-default-features --features ci-autoclean
cargo-cache
doc_tests:
name: Documentation Tests
name: doc tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Install Rust (nightly)
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
uses: actions-rs/toolchain@v1
with:
toolchain: nightly
toolchain: nightly-x86_64-unknown-linux-gnu
profile: minimal
override: true
- name: Install just
uses: taiki-e/install-action@v2.49.42
- name: Generate Cargo.lock
uses: actions-rs/cargo@v1
with: { command: generate-lockfile }
- name: Cache Dependencies
uses: Swatinem/rust-cache@v1.3.0
- name: doc tests
uses: actions-rs/cargo@v1
timeout-minutes: 40
with:
tool: just
- name: Test docs
run: just test-docs
- name: Build docs
run: just doc
command: ci-doctest
args: -- --nocapture

View File

@ -1,15 +1,11 @@
# disabled because `cargo tarpaulin` currently segfaults
name: Coverage
on:
push:
branches: [master]
permissions: { contents: read }
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
coverage:
runs-on: ubuntu-latest
@ -22,26 +18,25 @@ jobs:
options: --entrypoint redis-server
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
- name: Install stable
uses: actions-rs/toolchain@v1
with:
toolchain: nightly
components: llvm-tools-preview
toolchain: stable-x86_64-unknown-linux-gnu
profile: minimal
override: true
- name: Install just, cargo-llvm-cov, cargo-nextest
uses: taiki-e/install-action@v2.49.42
with:
tool: just,cargo-llvm-cov,cargo-nextest
- name: Generate code coverage
run: just test-coverage-codecov
- name: Generate Cargo.lock
uses: actions-rs/cargo@v1
with: { command: generate-lockfile }
- name: Cache Dependencies
uses: Swatinem/rust-cache@v1.2.0
- name: Generate coverage file
run: |
cargo install cargo-tarpaulin --vers "^0.13"
cargo tarpaulin --workspace --out Xml --verbose
- name: Upload to Codecov
uses: codecov/codecov-action@v5.4.0
with:
files: codecov.json
fail_ci_if_error: true
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
uses: codecov/codecov-action@v1
with: { file: cobertura.xml }

View File

@ -1,48 +1,39 @@
name: Lint
on: [pull_request]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust (nightly)
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
with:
toolchain: nightly
components: rustfmt
- name: Check with rustfmt
run: cargo fmt --all -- --check
clippy:
permissions:
contents: read
checks: write # to add clippy checks to PR diffs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
uses: actions-rs/toolchain@v1
with:
components: clippy
toolchain: stable
components: rustfmt
- name: Check with rustfmt
uses: actions-rs/cargo@v1
with:
command: fmt
args: --all -- --check
- name: Check with Clippy
uses: giraffate/clippy-action@v1.0.1
clippy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
reporter: github-pr-check
github_token: ${{ secrets.GITHUB_TOKEN }}
clippy_flags: >-
--workspace --all-features --tests --examples --bins --
-A unknown_lints -D clippy::todo -D clippy::dbg_macro
toolchain: stable
components: clippy
override: true
- name: Check with Clippy
uses: actions-rs/clippy-check@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
args: --workspace --tests --all-features

35
.github/workflows/upload-doc.yml vendored Normal file
View File

@ -0,0 +1,35 @@
name: Upload Documentation
on:
push:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: nightly-x86_64-unknown-linux-gnu
profile: minimal
override: true
- name: Build Docs
uses: actions-rs/cargo@v1
with:
command: doc
args: --workspace --all-features --no-deps
- name: Tweak HTML
run: echo '<meta http-equiv="refresh" content="0;url=actix_cors/index.html">' > target/doc/index.html
- name: Deploy to GitHub Pages
uses: JamesIves/github-pages-deploy-action@3.7.1
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH: gh-pages
FOLDER: target/doc

3
.gitignore vendored
View File

@ -1,5 +1,6 @@
/target
**/*.rs.bk
Cargo.lock
guide/build/
/gh-pages
@ -11,5 +12,3 @@ guide/build/
*.sock
*~
.DS_Store
Server.toml

View File

@ -1,5 +0,0 @@
overrides:
- files: "*.md"
options:
proseWrap: never
printWidth: 9999

3292
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,34 +3,17 @@ resolver = "2"
members = [
"actix-cors",
"actix-identity",
"actix-limitation",
"actix-protobuf",
# TODO: move this example to examples repo
# "actix-protobuf/examples/prost-example",
"actix-redis",
"actix-session",
"actix-settings",
"actix-web-httpauth",
"actix-ws",
]
[workspace.package]
repository = "https://github.com/actix/actix-extras"
homepage = "https://actix.rs"
license = "MIT OR Apache-2.0"
edition = "2021"
rust-version = "1.75"
[workspace.lints.rust]
rust-2018-idioms = { level = "deny" }
nonstandard-style = { level = "deny" }
future-incompatible = { level = "deny" }
[patch.crates-io]
actix-cors = { path = "./actix-cors" }
actix-identity = { path = "./actix-identity" }
actix-limitation = { path = "./actix-limitation" }
actix-protobuf = { path = "./actix-protobuf" }
actix-session = { path = "./actix-session" }
actix-settings = { path = "./actix-settings" }
actix-web-httpauth = { path = "./actix-web-httpauth" }
actix-cors = { path = "actix-cors" }
actix-session = { path = "actix-session" }
# uncomment to quickly test against local actix-web repo
# actix-http = { path = "../actix-web/actix-http" }

View File

@ -186,7 +186,8 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2017-NOW Actix team
Copyright 2017-NOW Nikolay Kim
Copyright 2017-NOW svartalf and Actix team
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

View File

@ -1,4 +1,5 @@
Copyright (c) 2023 Actix team
Copyright (c) 2017 Nikolay Kim
Copyright (c) 2017 svartalf and Actix team
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated

102
README.md
View File

@ -1,28 +1,22 @@
# actix-extras
> A collection of additional crates supporting [Actix Web].
<!-- prettier-ignore-start -->
> A collection of additional crates supporting the [actix-web] and [actix] frameworks.
[![CI](https://github.com/actix/actix-extras/actions/workflows/ci.yml/badge.svg)](https://github.com/actix/actix-extras/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/actix/actix-extras/branch/master/graph/badge.svg)](https://codecov.io/gh/actix/actix-extras)
[![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/5Ux4QGChWc)
[![Dependency Status](https://deps.rs/repo/github/actix/actix-extras/status.svg)](https://deps.rs/repo/github/actix/actix-extras)
<!-- prettier-ignore-end -->
## Crates by @actix
| Crate | | |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| [actix-cors] | [![crates.io](https://img.shields.io/crates/v/actix-cors?label=latest)](https://crates.io/crates/actix-cors) [![dependency status](https://deps.rs/crate/actix-cors/latest/status.svg)](https://deps.rs/crate/actix-cors) | Cross-Origin Resource Sharing (CORS) controls. |
| [actix-identity] | [![crates.io](https://img.shields.io/crates/v/actix-identity?label=latest)](https://crates.io/crates/actix-identity) [![dependency status](https://deps.rs/crate/actix-identity/latest/status.svg)](https://deps.rs/crate/actix-identity) | Identity management. |
| [actix-limitation] | [![crates.io](https://img.shields.io/crates/v/actix-limitation?label=latest)](https://crates.io/crates/actix-limitation) [![dependency status](https://deps.rs/crate/actix-limitation/latest/status.svg)](https://deps.rs/crate/actix-limitation) | Rate-limiting using a fixed window counter for arbitrary keys, backed by Redis. |
| [actix-protobuf] | [![crates.io](https://img.shields.io/crates/v/actix-protobuf?label=latest)](https://crates.io/crates/actix-protobuf) [![dependency status](https://deps.rs/crate/actix-protobuf/latest/status.svg)](https://deps.rs/crate/actix-protobuf) | Protobuf payload extractor. |
| [actix-session] | [![crates.io](https://img.shields.io/crates/v/actix-session?label=latest)](https://crates.io/crates/actix-session) [![dependency status](https://deps.rs/crate/actix-session/latest/status.svg)](https://deps.rs/crate/actix-session) | Session management. |
| [actix-settings] | [![crates.io](https://img.shields.io/crates/v/actix-settings?label=latest)](https://crates.io/crates/actix-settings) [![dependency status](https://deps.rs/crate/actix-settings/latest/status.svg)](https://deps.rs/crate/actix-settings) | Easily manage Actix Web's settings from a TOML file and environment variables. |
| [actix-web-httpauth] | [![crates.io](https://img.shields.io/crates/v/actix-web-httpauth?label=latest)](https://crates.io/crates/actix-web-httpauth) [![dependency status](https://deps.rs/crate/actix-web-httpauth/latest/status.svg)](https://deps.rs/crate/actix-web-httpauth) | HTTP authentication schemes. |
| [actix-ws] | [![crates.io](https://img.shields.io/crates/v/actix-ws?label=latest)][actix-ws] [![dependency status](https://deps.rs/crate/actix-ws/latest/status.svg)](https://deps.rs/crate/actix-ws) | WebSockets for Actix Web, without actors. |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| [actix-cors] | [![crates.io](https://img.shields.io/crates/v/actix-cors?label=latest)](https://crates.io/crates/actix-cors) [![dependency status](https://deps.rs/crate/actix-cors/0.5.4/status.svg)](https://deps.rs/crate/actix-cors/0.5.4) | Cross-origin resource sharing (CORS) for actix-web applications. |
| [actix-identity] | [![crates.io](https://img.shields.io/crates/v/actix-identity?label=latest)](https://crates.io/crates/actix-identity) [![dependency status](https://deps.rs/crate/actix-identity/0.3.1/status.svg)](https://deps.rs/crate/actix-identity/0.3.1) | Identity service for actix-web framework. |
| [actix-protobuf] | [![crates.io](https://img.shields.io/crates/v/actix-protobuf?label=latest)](https://crates.io/crates/actix-protobuf) [![dependency status](https://deps.rs/crate/actix-protobuf/0.6.0/status.svg)](https://deps.rs/crate/actix-protobuf/0.6.0) | Protobuf support for actix-web framework. |
| [actix-redis] | [![crates.io](https://img.shields.io/crates/v/actix-redis?label=latest)](https://crates.io/crates/actix-redis) [![dependency status](https://deps.rs/crate/actix-redis/0.9.1/status.svg)](https://deps.rs/crate/actix-redis/0.9.1) | Redis integration for actix framework. |
| [actix-session] | [![crates.io](https://img.shields.io/crates/v/actix-session?label=latest)](https://crates.io/crates/actix-session) [![dependency status](https://deps.rs/crate/actix-session/0.4.0/status.svg)](https://deps.rs/crate/actix-session/0.4.0) | Session for actix-web framework. |
| [actix-web-httpauth] | [![crates.io](https://img.shields.io/crates/v/actix-web-httpauth?label=latest)](https://crates.io/crates/actix-web-httpauth) [![dependency status](https://deps.rs/crate/actix-web-httpauth/0.5.0/status.svg)](https://deps.rs/crate/actix-web-httpauth/0.5.0) | HTTP authentication schemes for actix-web. |
---
@ -31,60 +25,40 @@
These crates are provided by the community.
| Crate | | |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| [actix-web-lab] | [![crates.io](https://img.shields.io/crates/v/actix-web-lab?label=latest)][actix-web-lab] [![dependency status](https://deps.rs/crate/actix-web-lab/latest/status.svg)](https://deps.rs/crate/actix-web-lab) | 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)][actix-form-data] [![dependency status](https://deps.rs/crate/actix-form-data/latest/status.svg)](https://deps.rs/crate/actix-form-data) | Multipart form data from actix multipart streams. |
| [actix-governor] | [![crates.io](https://img.shields.io/crates/v/actix-governor?label=latest)][actix-governor] [![dependency status](https://deps.rs/crate/actix-governor/latest/status.svg)](https://deps.rs/crate/actix-governor) | Rate-limiting backed by governor. |
| [actix-casbin] | [![crates.io](https://img.shields.io/crates/v/actix-casbin?label=latest)][actix-casbin] [![dependency status](https://deps.rs/crate/actix-casbin/latest/status.svg)](https://deps.rs/crate/actix-casbin) | Authorization library that supports access control models like ACL, RBAC & ABAC. |
| [actix-ip-filter] | [![crates.io](https://img.shields.io/crates/v/actix-ip-filter?label=latest)][actix-ip-filter] [![dependency status](https://deps.rs/crate/actix-ip-filter/latest/status.svg)](https://deps.rs/crate/actix-ip-filter) | IP address filter. Supports glob patterns. |
| [actix-web-static-files] | [![crates.io](https://img.shields.io/crates/v/actix-web-static-files?label=latest)][actix-web-static-files] [![dependency status](https://deps.rs/crate/actix-web-static-files/latest/status.svg)](https://deps.rs/crate/actix-web-static-files) | Static files as embedded resources. |
| [actix-web-grants] | [![crates.io](https://img.shields.io/crates/v/actix-web-grants?label=latest)][actix-web-grants] [![dependency status](https://deps.rs/crate/actix-web-grants/latest/status.svg)](https://deps.rs/crate/actix-web-grants) | Extension for validating user authorities. |
| [aliri_actix] | [![crates.io](https://img.shields.io/crates/v/aliri_actix?label=latest)][aliri_actix] [![dependency status](https://deps.rs/crate/aliri_actix/latest/status.svg)](https://deps.rs/crate/aliri_actix) | Endpoint authorization and authentication using scoped OAuth2 JWT tokens. |
| [actix-web-flash-messages] | [![crates.io](https://img.shields.io/crates/v/actix-web-flash-messages?label=latest)][actix-web-flash-messages] [![dependency status](https://deps.rs/crate/actix-web-flash-messages/latest/status.svg)](https://deps.rs/crate/actix-web-flash-messages) | Support for flash messages/one-time notifications in `actix-web`. |
| [awmp] | [![crates.io](https://img.shields.io/crates/v/awmp?label=latest)][awmp] [![dependency status](https://deps.rs/crate/awmp/latest/status.svg)](https://deps.rs/crate/awmp) | An easy to use wrapper around multipart fields for Actix Web. |
| [tracing-actix-web] | [![crates.io](https://img.shields.io/crates/v/tracing-actix-web?label=latest)][tracing-actix-web] [![dependency status](https://deps.rs/crate/tracing-actix-web/latest/status.svg)](https://deps.rs/crate/tracing-actix-web) | 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)][actix-hash] [![dependency status](https://deps.rs/crate/actix-hash/latest/status.svg)](https://deps.rs/crate/actix-hash) | Hashing utilities for Actix Web. |
| [actix-bincode] | ![crates.io](https://img.shields.io/crates/v/actix-bincode?label=latest) [![dependency status](https://deps.rs/crate/actix-bincode/latest/status.svg)](https://deps.rs/crate/actix-bincode) | Bincode payload extractor for Actix Web. |
| [sentinel-actix] | ![crates.io](https://img.shields.io/crates/v/sentinel-actix?label=latest) [![dependency status](https://deps.rs/crate/sentinel-actix/latest/status.svg)](https://deps.rs/crate/sentinel-actix) | General and flexible protection for Actix Web. |
| [actix-telepathy] | ![crates.io](https://img.shields.io/crates/v/actix-telepathy?label=latest) [![dependency status](https://deps.rs/crate/actix-telepathy/latest/status.svg)](https://deps.rs/crate/actix-telepathy) | Build distributed applications with `RemoteActors` and `RemoteMessages`. |
| [apistos] | ![crates.io](https://img.shields.io/crates/v/apistos?label=latest) [![dependency status](https://deps.rs/crate/apistos/latest/status.svg)](https://deps.rs/crate/apistos) | Automatic OpenAPI v3 documentation for Actix Web. |
| [actix-web-validation] | ![crates.io](https://img.shields.io/crates/v/actix-web-validation?label=latest) [![dependency status](https://deps.rs/crate/actix-web-validation/latest/status.svg)](https://deps.rs/crate/actix-web-validation) | Request validation for Actix Web. |
| [actix-jwt-cookies] | ![crates.io](https://img.shields.io/crates/v/actix-jwt-cookies?label=latest) [![dependency status](https://deps.rs/repo/github/Necoo33/actix-jwt-cookies/status.svg)](https://deps.rs/repo/github/Necoo33/actix-jwt-cookies?path=%2F) | Store your data in encrypted cookies and get it elegantly. |
| [actix-ws-broadcaster] | ![crates.io](https://img.shields.io/crates/v/actix-ws-broadcaster?label=latest) [![dependency status](https://deps.rs/repo/github/Necoo33/actix-ws-broadcaster/status.svg?path=%2F)](https://deps.rs/repo/github/Necoo33/actix-ws-broadcaster?path=%2F) | A broadcaster library for actix-ws that includes grouping and conditional broadcasting. |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| [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.5.0/status.svg)](https://deps.rs/crate/actix-form-data/0.5.0) | Rate-limiting backed by form-data. |
| [actix-governor] | [![crates.io](https://img.shields.io/crates/v/actix-governor?label=latest)](https://crates.io/crates/actix-governor) [![dependency status](https://deps.rs/crate/actix-governor/0.2.4/status.svg)](https://deps.rs/crate/actix-governor/0.2.4) | Rate-limiting backed by governor. |
| [actix-limitation] | [![crates.io](https://img.shields.io/crates/v/actix-limitation?label=latest)](https://crates.io/crates/actix-limitation) [![dependency status](https://deps.rs/crate/actix-limitation/0.1.4/status.svg)](https://deps.rs/crate/actix-limitation/0.1.4) | Rate-limiting using a fixed window counter for arbitrary keys, backed by Redis. |
| [actix-casbin] | [![crates.io](https://img.shields.io/crates/v/actix-casbin?label=latest)](https://crates.io/crates/actix-casbin) [![dependency status](https://deps.rs/crate/actix-casbin/0.4.2/status.svg)](https://deps.rs/crate/actix-casbin/0.4.2) | Authorization library that supports access control models like ACL, RBAC & ABAC. |
| [actix-ip-filter] | [![crates.io](https://img.shields.io/crates/v/actix-ip-filter?label=latest)](https://crates.io/crates/actix-ip-filter) [![dependency status](https://deps.rs/crate/actix-ip-filter/0.2.0/status.svg)](https://deps.rs/crate/actix-ip-filter/0.2.0) | IP address filter. Supports glob patterns. |
| [actix-web-static-files] | [![crates.io](https://img.shields.io/crates/v/actix-web-static-files?label=latest)](https://crates.io/crates/actix-web-static-files) [![dependency status](https://deps.rs/crate/actix-web-static-files/3.0.5/status.svg)](https://deps.rs/crate/actix-web-static-files/3.0.5) | Static files as embedded resources. |
| [actix-web-grants] | [![crates.io](https://img.shields.io/crates/v/actix-web-grants?label=latest)](https://crates.io/crates/actix-web-grants) [![dependency status](https://deps.rs/crate/actix-web-grants/2.0.1/status.svg)](https://deps.rs/crate/actix-web-grants/2.0.1) | Extension for validating user authorities. |
| [aliri_actix] | [![crates.io](https://img.shields.io/crates/v/aliri_actix?label=latest)](https://crates.io/crates/aliri_actix) [![dependency status](https://deps.rs/crate/aliri_actix/0.5.0/status.svg)](https://deps.rs/crate/aliri_actix/0.5.0) | Endpoint authorization and authentication using scoped OAuth2 JWT tokens. |
| [actix-web-flash-messages] | [![crates.io](https://img.shields.io/crates/v/actix-web-flash-messages?label=latest)](https://crates.io/crates/actix-web-flash-messages) [![dependency status](https://deps.rs/crate/actix-web-flash-messages/0.2.0/status.svg)](https://deps.rs/crate/actix-web-flash-messages/0.2.0) | Support for flash messages/one-time notifications in `actix-web`. |
| [awmp] | [![crates.io](https://img.shields.io/crates/v/awmp?label=latest)](https://crates.io/crates/awmp) [![dependency status](https://deps.rs/crate/awmp/0.7.0/status.svg)](https://deps.rs/crate/awmp/0.7.0) | An easy to use wrapper around multipart fields for Actix Web. |
| [tracing-actix-web] | [![crates.io](https://img.shields.io/crates/v/tracing-actix-web?label=latest)](https://crates.io/crates/tracing-actix-web) [![dependency status](https://deps.rs/crate/tracing-actix-web/0.5.0-beta.10/status.svg)](https://deps.rs/crate/tracing-actix-web/0.5.0-beta.10) | A middleware to collect telemetry data from applications built on top of the actix-web framework. |
To add a crate to this list, submit a pull request.
<!-- REFERENCES -->
[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-cors]: ./actix-cors
[actix-identity]: ./actix-identity
[actix-limitation]: ./actix-limitation
[actix-protobuf]: ./actix-protobuf
[actix-session]: ./actix-session
[actix-settings]: ./actix-settings
[actix-web-httpauth]: ./actix-web-httpauth
[actix-web-lab]: https://crates.io/crates/actix-web-lab
[actix-multipart-extract]: https://crates.io/crates/actix-multipart-extract
[actix-form-data]: https://crates.io/crates/actix-form-data
[actix-casbin]: https://crates.io/crates/actix-casbin
[actix-ip-filter]: https://crates.io/crates/actix-ip-filter
[actix-web-static-files]: https://crates.io/crates/actix-web-static-files
[actix-web-grants]: https://crates.io/crates/actix-web-grants
[actix-web-flash-messages]: https://crates.io/crates/actix-web-flash-messages
[actix-governor]: https://crates.io/crates/actix-governor
[aliri_actix]: https://crates.io/crates/aliri_actix
[awmp]: https://crates.io/crates/awmp
[tracing-actix-web]: https://crates.io/crates/tracing-actix-web
[actix-ws]: https://crates.io/crates/actix-ws
[actix-hash]: https://crates.io/crates/actix-hash
[actix-bincode]: https://crates.io/crates/actix-bincode
[sentinel-actix]: https://crates.io/crates/sentinel-actix
[actix-telepathy]: https://crates.io/crates/actix-telepathy
[actix-web-validation]: https://crates.io/crates/actix-web-validation
[actix-telepathy]: https://crates.io/crates/actix-telepathy
[apistos]: https://crates.io/crates/apistos
[actix-jwt-cookies]: https://crates.io/crates/actix-jwt-cookies
[actix-ws-broadcaster]: https://crates.io/crates/actix-ws-broadcaster
[actix-cors]: actix-cors
[actix-identity]: actix-identity
[actix-protobuf]: actix-protobuf
[actix-redis]: actix-redis
[actix-session]: actix-session
[actix-web-httpauth]: actix-web-httpauth
[actix-form-data]: https://git.asonix.dog/asonix/actix-form-data
[actix-limitation]: https://github.com/0xmad/actix-limitation
[actix-casbin]: https://github.com/casbin-rs/actix-casbin
[actix-ip-filter]: https://github.com/jhen0409/actix-ip-filter
[actix-web-static-files]: https://github.com/kilork/actix-web-static-files
[actix-web-grants]: https://github.com/DDtKey/actix-web-grants
[actix-web-flash-messages]: https://github.com/LukeMathWalker/actix-web-flash-messages
[actix-governor]: https://github.com/AaronErhardt/actix-governor
[aliri_actix]: https://github.com/neoeinstein/aliri
[awmp]: https://github.com/kardeiz/awmp
[tracing-actix-web]: https://github.com/LukeMathWalker/tracing-actix-web

View File

@ -1,88 +1,55 @@
# Changes
## Unreleased
## Unreleased - 2021-xx-xx
## 0.7.1
- Implement `PartialEq` for `Cors` allowing for better testing.
## 0.7.0
- `Cors` is now marked `#[must_use]`.
- Default for `Cors::block_on_origin_mismatch` is now false.
- Minimum supported Rust version (MSRV) is now 1.75.
## 0.6.5
- Fix `Vary` header when Private Network Access is enabled.
- Minimum supported Rust version (MSRV) is now 1.68.
## 0.6.4
- Add `Cors::allow_private_network_access()` behind an unstable flag (`draft-private-network-access`).
## 0.6.3
- Add `Cors::block_on_origin_mismatch()` option for controlling if requests are pre-emptively rejected.
- Minimum supported Rust version (MSRV) is now 1.59 due to transitive `time` dependency.
## 0.6.2
- Fix `expose_any_header` to return list of response headers.
- Minimum supported Rust version (MSRV) is now 1.57 due to transitive `time` dependency.
## 0.6.1
- Do not consider requests without a `Access-Control-Request-Method` as preflight.
## 0.6.0
## 0.6.0 - 2022-02-25
- Update `actix-web` dependency to 4.0.
<details>
<summary>0.6.0 pre-releases</summary>
## 0.6.0-beta.10
- Ensure that preflight responses contain a `Vary` header. [#224]
[#224]: https://github.com/actix/actix-extras/pull/224
## 0.6.0-beta.9
## 0.6.0-beta.10 - 2022-02-07
- Ensure that preflight responses contain a Vary header. [#224]
[#224]: https://github.com/actix/actix-extras/pull/224
## 0.6.0-beta.9 - 2022-02-07
- Relax body type bounds on middleware impl. [#223]
- Update `actix-web` dependency to `4.0.0-rc.1`.
[#223]: https://github.com/actix/actix-extras/pull/223
## 0.6.0-beta.8
## 0.6.0-beta.8 - 2021-12-29
- Minimum supported Rust version (MSRV) is now 1.54.
## 0.6.0-beta.7
## 0.6.0-beta.7 - 2021-12-18
- Update `actix-web` dependency to `4.0.0-beta.15`. [#216]
[#216]: https://github.com/actix/actix-extras/pull/216
## 0.6.0-beta.6
## 0.6.0-beta.6 - 2021-12-13
- Fix panic when wrapping routes with dynamic segments in their paths. [#213]
[#213]: https://github.com/actix/actix-extras/pull/213
## 0.6.0-beta.5 _(YANKED)_
## 0.6.0-beta.5 - 2021-12-12 _(YANKED)_
- Update `actix-web` dependency to `4.0.0.beta-14`. [#209]
[#209]: https://github.com/actix/actix-extras/pull/209
## 0.6.0-beta.4
## 0.6.0-beta.4 - 2021-11-22
- No significant changes since `0.6.0-beta.3`.
## 0.6.0-beta.3
## 0.6.0-beta.3 - 2021-10-21
- Make `Cors` middleware generic over body type [#195]
- Fix `expose_any_header` behavior. [#204]
- Update `actix-web` dependency to v4.0.0-beta.10. [#203]
@ -92,90 +59,90 @@
[#203]: https://github.com/actix/actix-extras/pull/203
[#204]: https://github.com/actix/actix-extras/pull/204
## 0.6.0-beta.2
## 0.6.0-beta.2 - 2021-06-27
- No notable changes.
## 0.6.0-beta.1
## 0.6.0-beta.1 - 2021-04-02
- Update `actix-web` dependency to 4.0.0 beta.
- Minimum supported Rust version (MSRV) is now 1.46.0.
</details>
## 0.5.4
## 0.5.4 - 2020-12-31
- Fix `expose_any_header` method, now set the correct field. [#143]
[#143]: https://github.com/actix/actix-extras/pull/143
## 0.5.3
## 0.5.3 - 2020-11-19
- Fix version spec for `derive_more` dependency.
## 0.5.2
## 0.5.2 - 2020-11-15
- Ensure `tinyvec` is using the correct features.
- Bump `futures-util` minimum version to `0.3.7` to avoid `RUSTSEC-2020-0059`.
## 0.5.1
## 0.5.1 - 2020-11-05
- Fix `allow_any_header` method, now set the correct field. [#121]
[#121]: https://github.com/actix/actix-extras/pull/121
## 0.5.0
## 0.5.0 - 2020-10-19
- Disallow `*` in `Cors::allowed_origin`. [#114].
- Hide `CorsMiddleware` from docs. [#118].
- `CorsFactory` is removed. [#119]
- The `impl Default` constructor is now overly-restrictive. [#119]
- Added `Cors::permissive()` constructor that allows anything. [#119]
- Adds methods for each property to reset to a permissive state. (`allow_any_origin`, `expose_any_header`, etc.) [#119]
- Adds methods for each property to reset to a permissive state. (`allow_any_origin`,
`expose_any_header`, etc.) [#119]
- Errors are now propagated with `Transform::InitError` instead of panicking. [#119]
- Fixes bug where allowed origin functions are not called if `allowed_origins` is All. [#119]
- `AllOrSome` is no longer public. [#119]
- Functions used for `allowed_origin_fn` now receive the Origin HeaderValue as the first parameter. [#120]
- Functions used for `allowed_origin_fn` now receive the Origin HeaderValue as the
first parameter. [#120]
[#114]: https://github.com/actix/actix-extras/pull/114
[#118]: https://github.com/actix/actix-extras/pull/118
[#119]: https://github.com/actix/actix-extras/pull/119
[#120]: https://github.com/actix/actix-extras/pull/120
## 0.4.1
## 0.4.1 - 2020-10-07
- Allow closures to be used with `allowed_origin_fn`. [#110]
[#110]: https://github.com/actix/actix-extras/pull/110
## 0.4.0
## 0.4.0 - 2020-09-27
- Implement `allowed_origin_fn` builder method. [#93]
- Use `TryInto` instead of `TryFrom` where applicable. [#106]
[#93]: https://github.com/actix/actix-extras/pull/93
[#106]: https://github.com/actix/actix-extras/pull/106
## 0.3.0
## 0.3.0 - 2020-09-11
- Update `actix-web` dependency to 3.0.0.
- Minimum supported Rust version (MSRV) is now 1.42.0.
- Implement the Debug trait on all public types.
## 0.3.0-alpha.1
## 0.3.0-alpha.1 - 2020-03-11
- Minimize `futures-*` dependencies
- Update `actix-web` dependency to 3.0.0-alpha.1
## 0.2.0 - 2019-12-20
## 0.2.0 - 2019-12-20
- Release
## 0.2.0-alpha.3 - 2019-12-07
## 0.2.0-alpha.3 - 2019-12-07
- Migrate to actix-web 2.0.0
- Bump `derive_more` crate version to 0.99.0
## 0.1.0 - 2019-06-15
## 0.1.0 - 2019-06-15
- Move cors middleware to separate crate

View File

@ -1,39 +1,33 @@
[package]
name = "actix-cors"
version = "0.7.1"
version = "0.6.0"
authors = [
"Nikolay Kim <fafhrd91@gmail.com>",
"Rob Ede <robjtede@icloud.com>",
]
description = "Cross-Origin Resource Sharing (CORS) controls for Actix Web"
keywords = ["actix", "cors", "web", "security", "crossorigin"]
repository.workspace = true
homepage.workspace = true
license.workspace = true
edition.workspace = true
rust-version.workspace = true
homepage = "https://actix.rs"
repository = "https://github.com/actix/actix-extras.git"
license = "MIT OR Apache-2.0"
edition = "2018"
[package.metadata.docs.rs]
rustdoc-args = ["--cfg", "docsrs"]
all-features = true
[features]
draft-private-network-access = []
[lib]
name = "actix_cors"
path = "src/lib.rs"
[dependencies]
actix-service = "2"
actix-utils = "3"
actix-web = { version = "4", default-features = false }
derive_more = { version = "2", features = ["display", "error"] }
futures-util = { version = "0.3.17", default-features = false, features = ["std"] }
derive_more = "0.99.5"
futures-util = { version = "0.3.7", default-features = false }
log = "0.4"
once_cell = "1"
smallvec = "1"
smallvec = "1.6.1"
[dev-dependencies]
actix-web = { version = "4", default-features = false, features = ["macros"] }
env_logger = "0.11"
actix-web = { version = "4", default_features = false, features = ["macros"] }
env_logger = "0.9"
regex = "1.4"
[lints]
workspace = true

View File

@ -1,72 +1,14 @@
# actix-cors
<!-- prettier-ignore-start -->
> Cross-origin resource sharing (CORS) for Actix Web.
[![crates.io](https://img.shields.io/crates/v/actix-cors?label=latest)](https://crates.io/crates/actix-cors)
[![Documentation](https://docs.rs/actix-cors/badge.svg?version=0.7.1)](https://docs.rs/actix-cors/0.7.1)
![Version](https://img.shields.io/badge/rustc-1.75+-ab6000.svg)
![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-cors.svg)
<br />
[![Dependency Status](https://deps.rs/crate/actix-cors/0.7.1/status.svg)](https://deps.rs/crate/actix-cors/0.7.1)
[![Download](https://img.shields.io/crates/d/actix-cors.svg)](https://crates.io/crates/actix-cors)
[![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x)
<!-- prettier-ignore-end -->
<!-- cargo-rdme start -->
Cross-Origin Resource Sharing (CORS) controls for Actix Web.
This middleware can be applied to both applications and resources. Once built, a [`Cors`] builder can be used as an argument for Actix Web's `App::wrap()`, `Scope::wrap()`, or `Resource::wrap()` methods.
This CORS middleware automatically handles `OPTIONS` preflight requests.
## Crate Features
- `draft-private-network-access`: ⚠️ Unstable. Adds opt-in support for the [Private Network Access] spec extensions. This feature is unstable since it will follow breaking changes in the draft spec until it is finalized.
## Example
```rust
use actix_cors::Cors;
use actix_web::{get, http, web, App, HttpRequest, HttpResponse, HttpServer};
#[get("/index.html")]
async fn index(req: HttpRequest) -> &'static str {
"<p>Hello World!</p>"
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
let cors = Cors::default()
.allowed_origin("https://www.rust-lang.org")
.allowed_origin_fn(|origin, _req_head| {
origin.as_bytes().ends_with(b".rust-lang.org")
})
.allowed_methods(vec!["GET", "POST"])
.allowed_headers(vec![http::header::AUTHORIZATION, http::header::ACCEPT])
.allowed_header(http::header::CONTENT_TYPE)
.max_age(3600);
App::new()
.wrap(cors)
.service(index)
})
.bind(("127.0.0.1", 8080))?
.run()
.await;
Ok(())
}
```
[Private Network Access]: https://wicg.github.io/private-network-access
<!-- cargo-rdme end -->
[![Documentation](https://docs.rs/actix-cors/badge.svg?version=0.6.0)](https://docs.rs/actix-cors/0.6.0)
![Apache 2.0 or MIT licensed](https://img.shields.io/crates/l/actix-cors)
[![Dependency Status](https://deps.rs/crate/actix-cors/0.6.0/status.svg)](https://deps.rs/crate/actix-cors/0.6.0)
## Documentation & Resources
- [API Documentation](https://docs.rs/actix-cors)
- [Example Project](https://github.com/actix/examples/tree/master/cors)
- Minimum Supported Rust Version (MSRV): 1.75
- Minimum Supported Rust Version (MSRV): 1.54

View File

@ -39,8 +39,6 @@ async fn main() -> std::io::Result<()> {
.allowed_header(header::CONTENT_TYPE)
// set list of headers that are safe to expose
.expose_headers(&[header::CONTENT_DISPOSITION])
// allow cURL/HTTPie from working without providing Origin headers
.block_on_origin_mismatch(false)
// set preflight cache TTL
.max_age(3600),
)

View File

@ -1,5 +1,5 @@
/// An enum signifying that some of type `T` is allowed, or `All` (anything is allowed).
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AllOrSome<T> {
/// Everything is allowed. Usually equivalent to the `*` value.
All,

View File

@ -1,4 +1,4 @@
use std::{collections::HashSet, rc::Rc};
use std::{collections::HashSet, convert::TryInto, iter::FromIterator, rc::Rc};
use actix_utils::future::{self, Ready};
use actix_web::{
@ -52,20 +52,13 @@ static ALL_METHODS_SET: Lazy<HashSet<Method>> = Lazy::new(|| {
/// The alternative [`Cors::permissive()`] constructor is available for local development, allowing
/// all origins and headers, etc. **The permissive constructor should not be used in production.**
///
/// # Behavior
///
/// In all cases, behavior for this crate follows the [Fetch Standard CORS protocol]. See that
/// document for information on exact semantics for configuration options and combinations.
///
/// # Errors
///
/// Errors surface in the middleware initialization phase. This means that, if you have logs enabled
/// in Actix Web (using `env_logger` or other crate that exposes logs from the `log` crate), error
/// messages will outline what is wrong with the CORS configuration in the server logs and the
/// server will fail to start up or serve requests.
///
/// # Example
///
/// ```
/// use actix_cors::Cors;
/// use actix_web::http::header;
@ -79,18 +72,14 @@ static ALL_METHODS_SET: Lazy<HashSet<Method>> = Lazy::new(|| {
///
/// // `cors` can now be used in `App::wrap`.
/// ```
///
/// [Fetch Standard CORS protocol]: https://fetch.spec.whatwg.org/#http-cors-protocol
#[derive(Debug)]
#[must_use]
pub struct Cors {
inner: Rc<Inner>,
error: Option<Either<HttpError, CorsError>>,
}
impl Cors {
/// Constructs a very permissive set of defaults for quick development. (Not recommended for
/// production use.)
/// A very permissive set of default for quick development. Not recommended for production use.
///
/// *All* origins, methods, request headers and exposed headers allowed. Credentials supported.
/// Max age 1 hour. Does not send wildcard.
@ -112,10 +101,7 @@ impl Cors {
preflight: true,
send_wildcard: false,
supports_credentials: true,
#[cfg(feature = "draft-private-network-access")]
allow_private_network_access: false,
vary_header: true,
block_on_origin_mismatch: false,
};
Cors {
@ -135,12 +121,12 @@ impl Cors {
self
}
/// Adds an origin that is allowed to make requests.
/// Add an origin that is allowed to make requests.
///
/// This method allows specifying a finite set of origins to verify the value of the `Origin`
/// request header. These are `origin-or-null` types in the [Fetch Standard].
/// By default, requests from all origins are accepted by CORS logic. This method allows to
/// specify a finite set of origins to verify the value of the `Origin` request header.
///
/// By default, no origins are accepted.
/// These are `origin-or-null` types in the [Fetch Standard].
///
/// When this list is set, the client's `Origin` request header will be checked in a
/// case-sensitive manner.
@ -188,7 +174,7 @@ impl Cors {
self
}
/// Determinates allowed origins by processing requests which didn't match any origins specified
/// Determinate allowed origins by processing requests which didn't match any origins specified
/// in the `allowed_origin`.
///
/// The function will receive two parameters, the Origin header value, and the `RequestHead` of
@ -214,17 +200,20 @@ impl Cors {
/// See [`Cors::allowed_methods`] for more info on allowed methods.
pub fn allow_any_method(mut self) -> Cors {
if let Some(cors) = cors(&mut self.inner, &self.error) {
ALL_METHODS_SET.clone_into(&mut cors.allowed_methods);
cors.allowed_methods = ALL_METHODS_SET.clone();
}
self
}
/// Sets a list of methods which allowed origins can perform.
/// Set a list of methods which allowed origins can perform.
///
/// These will be sent in the `Access-Control-Allow-Methods` response header.
/// These will be sent in the `Access-Control-Allow-Methods` response header as specified in
/// the [Fetch Standard CORS protocol].
///
/// This defaults to an empty set.
/// Defaults to `[GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE]`
///
/// [Fetch Standard CORS protocol]: https://fetch.spec.whatwg.org/#http-cors-protocol
pub fn allowed_methods<U, M>(mut self, methods: U) -> Cors
where
U: IntoIterator<Item = M>,
@ -287,13 +276,16 @@ impl Cors {
self
}
/// Sets a list of request header field names which can be used when this resource is accessed
/// by allowed origins.
/// Set a list of request header field names which can be used when this resource is accessed by
/// allowed origins.
///
/// If `All` is set, whatever is requested by the client in `Access-Control-Request-Headers`
/// will be echoed back in the `Access-Control-Allow-Headers` header.
/// will be echoed back in the `Access-Control-Allow-Headers` header as specified in
/// the [Fetch Standard CORS protocol].
///
/// This defaults to an empty set.
/// Defaults to `All`.
///
/// [Fetch Standard CORS protocol]: https://fetch.spec.whatwg.org/#http-cors-protocol
pub fn allowed_headers<U, H>(mut self, headers: U) -> Cors
where
U: IntoIterator<Item = H>,
@ -323,7 +315,7 @@ impl Cors {
self
}
/// Resets exposed response header list to a state where all headers are exposed.
/// Resets exposed response header list to a state where any header is accepted.
///
/// See [`Cors::expose_headers`] for more info on exposed response headers.
pub fn expose_any_header(mut self) -> Cors {
@ -334,11 +326,13 @@ impl Cors {
self
}
/// Sets a list of headers which are safe to expose to the API of a CORS API specification.
///
/// This corresponds to the `Access-Control-Expose-Headers` response header.
/// Set a list of headers which are safe to expose to the API of a CORS API specification.
/// This corresponds to the `Access-Control-Expose-Headers` response header as specified in
/// the [Fetch Standard CORS protocol].
///
/// This defaults to an empty set.
///
/// [Fetch Standard CORS protocol]: https://fetch.spec.whatwg.org/#http-cors-protocol
pub fn expose_headers<U, H>(mut self, headers: U) -> Cors
where
U: IntoIterator<Item = H>,
@ -367,76 +361,64 @@ impl Cors {
self
}
/// Sets a maximum time (in seconds) for which this CORS request may be cached.
///
/// This value is set as the `Access-Control-Max-Age` header.
/// Set a maximum time (in seconds) for which this CORS request maybe cached.
/// This value is set as the `Access-Control-Max-Age` header as specified in
/// the [Fetch Standard CORS protocol].
///
/// Pass a number (of seconds) or use None to disable sending max age header.
///
/// [Fetch Standard CORS protocol]: https://fetch.spec.whatwg.org/#http-cors-protocol
pub fn max_age(mut self, max_age: impl Into<Option<usize>>) -> Cors {
if let Some(cors) = cors(&mut self.inner, &self.error) {
cors.max_age = max_age.into();
cors.max_age = max_age.into()
}
self
}
/// Configures use of wildcard (`*`) origin in responses when appropriate.
/// Set to use wildcard origins.
///
/// If send wildcard is set and the `allowed_origins` parameter is `All`, a wildcard
/// `Access-Control-Allow-Origin` response header is sent, rather than the requests
/// `Origin` header.
///
/// This option **CANNOT** be used in conjunction with a [credential
/// supported](Self::supports_credentials()) configuration. Doing so will result in an error
/// during server startup.
/// This **CANNOT** be used in conjunction with `allowed_origins` set to `All` and
/// `allow_credentials` set to `true`. Depending on the mode of usage, this will either result
/// in an `CorsError::CredentialsWithWildcardOrigin` error during actix launch or runtime.
///
/// Defaults to disabled.
/// Defaults to `false`.
pub fn send_wildcard(mut self) -> Cors {
if let Some(cors) = cors(&mut self.inner, &self.error) {
cors.send_wildcard = true;
cors.send_wildcard = true
}
self
}
/// Allows users to make authenticated requests.
/// Allows users to make authenticated requests
///
/// If true, injects the `Access-Control-Allow-Credentials` header in responses. This allows
/// cookies and credentials to be submitted across domains.
/// cookies and credentials to be submitted across domains as specified in
/// the [Fetch Standard CORS protocol].
///
/// This option **CANNOT** be used in conjunction with option cannot be used in conjunction
/// with [wildcard origins](Self::send_wildcard()) configured. Doing so will result in an error
/// during server startup.
///
/// Defaults to disabled.
pub fn supports_credentials(mut self) -> Cors {
if let Some(cors) = cors(&mut self.inner, &self.error) {
cors.supports_credentials = true;
}
self
}
/// Allow private network access.
///
/// If true, injects the `Access-Control-Allow-Private-Network: true` header in responses if the
/// request contained the `Access-Control-Request-Private-Network: true` header.
///
/// For more information on this behavior, see the draft [Private Network Access] spec.
/// This option cannot be used in conjunction with an `allowed_origin` set to `All` and
/// `send_wildcards` set to `true`.
///
/// Defaults to `false`.
///
/// [Private Network Access]: https://wicg.github.io/private-network-access
#[cfg(feature = "draft-private-network-access")]
pub fn allow_private_network_access(mut self) -> Cors {
/// A server initialization error will occur if credentials are allowed, but the Origin is set
/// to send wildcards (`*`); this is not allowed by the CORS protocol.
///
/// [Fetch Standard CORS protocol]: https://fetch.spec.whatwg.org/#http-cors-protocol
pub fn supports_credentials(mut self) -> Cors {
if let Some(cors) = cors(&mut self.inner, &self.error) {
cors.allow_private_network_access = true;
cors.supports_credentials = true
}
self
}
/// Disables `Vary` header support.
/// Disable `Vary` header support.
///
/// When enabled the header `Vary: Origin` will be returned as per the Fetch Standard
/// implementation guidelines.
@ -448,39 +430,21 @@ impl Cors {
/// By default, `Vary` header support is enabled.
pub fn disable_vary_header(mut self) -> Cors {
if let Some(cors) = cors(&mut self.inner, &self.error) {
cors.vary_header = false;
cors.vary_header = false
}
self
}
/// Disables preflight request handling.
/// Disable support for preflight requests.
///
/// When enabled CORS middleware automatically handles `OPTIONS` requests. This is useful for
/// application level middleware.
/// When enabled CORS middleware automatically handles `OPTIONS` requests.
/// This is useful for application level middleware.
///
/// By default, preflight support is enabled.
/// By default *preflight* support is enabled.
pub fn disable_preflight(mut self) -> Cors {
if let Some(cors) = cors(&mut self.inner, &self.error) {
cors.preflight = false;
}
self
}
/// Configures whether requests should be pre-emptively blocked on mismatched origin.
///
/// If `true`, a 400 Bad Request is returned immediately when a request fails origin validation.
///
/// If `false`, the request will be processed as normal but relevant CORS headers will not be
/// appended to the response. In this case, the browser is trusted to validate CORS headers and
/// and block requests based on pre-flight requests. Use this setting to allow cURL and other
/// non-browser HTTP clients to function as normal, no matter what `Origin` the request has.
///
/// Defaults to false.
pub fn block_on_origin_mismatch(mut self, block: bool) -> Cors {
if let Some(cors) = cors(&mut self.inner, &self.error) {
cors.block_on_origin_mismatch = block;
cors.preflight = false
}
self
@ -510,10 +474,7 @@ impl Default for Cors {
preflight: true,
send_wildcard: false,
supports_credentials: false,
#[cfg(feature = "draft-private-network-access")]
allow_private_network_access: false,
vary_header: true,
block_on_origin_mismatch: false,
};
Cors {
@ -608,27 +569,14 @@ where
.unwrap()
}
impl PartialEq for Cors {
fn eq(&self, other: &Self) -> bool {
self.inner == other.inner
// Because of the cors-function, checking if the content is equal implies that the errors are equal
//
// Proof by contradiction:
// Lets assume that the inner values are equal, but the error values are not.
// This means there had been an error, which has been fixed.
// This cannot happen as the first call to set the invalid value means that further usages of the cors-function will reject other input.
// => inner has to be in a different state
}
}
#[cfg(test)]
mod test {
use std::convert::Infallible;
use std::convert::{Infallible, TryInto};
use actix_web::{
body,
dev::fn_service,
http::StatusCode,
dev::{fn_service, Transform},
http::{header::HeaderName, StatusCode},
test::{self, TestRequest},
HttpResponse,
};
@ -659,9 +607,8 @@ mod test {
.insert_header(("Origin", "https://www.example.com"))
.to_srv_request();
let res = test::call_service(&cors, req).await;
assert_eq!(res.status(), StatusCode::OK);
assert!(!res.headers().contains_key("Access-Control-Allow-Origin"));
let resp = test::call_service(&cors, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[actix_web::test]
@ -692,11 +639,4 @@ mod test {
Cors::default().new_transform(srv).await.unwrap();
}
#[test]
fn impl_eq() {
assert_eq!(Cors::default(), Cors::default());
assert_ne!(Cors::default().send_wildcard(), Cors::default());
assert_ne!(Cors::default(), Cors::permissive());
}
}

View File

@ -1,40 +1,43 @@
use actix_web::{http::StatusCode, HttpResponse, ResponseError};
use derive_more::derive::{Display, Error};
use derive_more::{Display, Error};
use crate::inner::add_vary_header;
/// Errors that can occur when processing CORS guarded requests.
#[derive(Debug, Clone, Display, Error)]
#[non_exhaustive]
pub enum CorsError {
/// Allowed origin argument must not be wildcard (`*`).
#[display("`allowed_origin` argument must not be wildcard (`*`)")]
#[display(fmt = "`allowed_origin` argument must not be wildcard (`*`)")]
WildcardOrigin,
/// Request header `Origin` is required but was not provided.
#[display("Request header `Origin` is required but was not provided")]
#[display(fmt = "Request header `Origin` is required but was not provided")]
MissingOrigin,
/// Request header `Access-Control-Request-Method` is required but is missing.
#[display("Request header `Access-Control-Request-Method` is required but is missing")]
#[display(fmt = "Request header `Access-Control-Request-Method` is required but is missing")]
MissingRequestMethod,
/// Request header `Access-Control-Request-Method` has an invalid value.
#[display("Request header `Access-Control-Request-Method` has an invalid value")]
#[display(fmt = "Request header `Access-Control-Request-Method` has an invalid value")]
BadRequestMethod,
/// Request header `Access-Control-Request-Headers` has an invalid value.
#[display("Request header `Access-Control-Request-Headers` has an invalid value")]
#[display(fmt = "Request header `Access-Control-Request-Headers` has an invalid value")]
BadRequestHeaders,
/// Origin is not allowed to make this request.
#[display("Origin is not allowed to make this request")]
#[display(fmt = "Origin is not allowed to make this request")]
OriginNotAllowed,
/// Request method is not allowed.
#[display("Requested method is not allowed")]
#[display(fmt = "Requested method is not allowed")]
MethodNotAllowed,
/// One or more request headers are not allowed.
#[display("One or more request headers are not allowed")]
#[display(fmt = "One or more request headers are not allowed")]
HeadersNotAllowed,
}
@ -44,6 +47,8 @@ impl ResponseError for CorsError {
}
fn error_response(&self) -> HttpResponse {
HttpResponse::with_body(self.status_code(), self.to_string()).map_into_boxed_body()
let mut res = HttpResponse::with_body(self.status_code(), self.to_string());
add_vary_header(res.headers_mut());
res.map_into_boxed_body()
}
}

View File

@ -1,4 +1,4 @@
use std::{collections::HashSet, fmt, rc::Rc};
use std::{collections::HashSet, convert::TryFrom, convert::TryInto, fmt, rc::Rc};
use actix_web::{
dev::RequestHead,
@ -15,7 +15,6 @@ use crate::{AllOrSome, CorsError};
#[derive(Clone)]
pub(crate) struct OriginFn {
#[allow(clippy::type_complexity)]
pub(crate) boxed_fn: Rc<dyn Fn(&HeaderValue, &RequestHead) -> bool>,
}
@ -27,12 +26,6 @@ impl Default for OriginFn {
}
}
impl PartialEq for OriginFn {
fn eq(&self, other: &Self) -> bool {
Rc::ptr_eq(&self.boxed_fn, &other.boxed_fn)
}
}
impl fmt::Debug for OriginFn {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("origin_fn")
@ -40,13 +33,13 @@ impl fmt::Debug for OriginFn {
}
/// Try to parse header value as HTTP method.
pub(crate) fn header_value_try_into_method(hdr: &HeaderValue) -> Option<Method> {
fn header_value_try_into_method(hdr: &HeaderValue) -> Option<Method> {
hdr.to_str()
.ok()
.and_then(|meth| Method::try_from(meth).ok())
}
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone)]
pub(crate) struct Inner {
pub(crate) allowed_origins: AllOrSome<HashSet<HeaderValue>>,
pub(crate) allowed_origins_fns: SmallVec<[OriginFn; 4]>,
@ -65,22 +58,17 @@ pub(crate) struct Inner {
pub(crate) preflight: bool,
pub(crate) send_wildcard: bool,
pub(crate) supports_credentials: bool,
#[cfg(feature = "draft-private-network-access")]
pub(crate) allow_private_network_access: bool,
pub(crate) vary_header: bool,
pub(crate) block_on_origin_mismatch: bool,
}
static EMPTY_ORIGIN_SET: Lazy<HashSet<HeaderValue>> = Lazy::new(HashSet::new);
impl Inner {
/// The bool returned in Ok(_) position indicates whether the `Access-Control-Allow-Origin`
/// header should be added to the response or not.
pub(crate) fn validate_origin(&self, req: &RequestHead) -> Result<bool, CorsError> {
pub(crate) fn validate_origin(&self, req: &RequestHead) -> Result<(), CorsError> {
// return early if all origins are allowed or get ref to allowed origins set
#[allow(clippy::mutable_key_type)]
let allowed_origins = match &self.allowed_origins {
AllOrSome::All if self.allowed_origins_fns.is_empty() => return Ok(true),
AllOrSome::All if self.allowed_origins_fns.is_empty() => return Ok(()),
AllOrSome::Some(allowed_origins) => allowed_origins,
// only function origin validators are defined
_ => &EMPTY_ORIGIN_SET,
@ -91,11 +79,9 @@ impl Inner {
// origin header exists and is a string
Some(origin) => {
if allowed_origins.contains(origin) || self.validate_origin_fns(origin, req) {
Ok(true)
} else if self.block_on_origin_mismatch {
Err(CorsError::OriginNotAllowed)
Ok(())
} else {
Ok(false)
Err(CorsError::OriginNotAllowed)
}
}
@ -155,7 +141,7 @@ impl Inner {
// method invalid
Some(_) => Err(CorsError::BadRequestMethod),
// method missing so this is not a preflight request
// method missing
None => Err(CorsError::MissingRequestMethod),
}
}
@ -222,20 +208,8 @@ pub(crate) fn add_vary_header(headers: &mut HeaderMap) {
let mut val: Vec<u8> = Vec::with_capacity(hdr.len() + 71);
val.extend(hdr.as_bytes());
val.extend(b", Origin, Access-Control-Request-Method, Access-Control-Request-Headers");
#[cfg(feature = "draft-private-network-access")]
val.extend(b", Access-Control-Request-Private-Network");
val.try_into().unwrap()
}
#[cfg(feature = "draft-private-network-access")]
None => HeaderValue::from_static(
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers, \
Access-Control-Request-Private-Network",
),
#[cfg(not(feature = "draft-private-network-access"))]
None => HeaderValue::from_static(
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
),
@ -267,7 +241,6 @@ mod test {
async fn test_validate_not_allowed_origin() {
let cors = Cors::default()
.allowed_origin("https://www.example.com")
.block_on_origin_mismatch(true)
.new_transform(test::ok_service())
.await
.unwrap();
@ -304,7 +277,7 @@ mod test {
assert!(cors.inner.validate_allowed_method(req.head()).is_err());
assert!(cors.inner.validate_allowed_headers(req.head()).is_err());
let resp = test::call_service(&cors, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let req = TestRequest::default()
.method(Method::OPTIONS)

View File

@ -1,16 +1,11 @@
//! Cross-Origin Resource Sharing (CORS) controls for Actix Web.
//!
//! This middleware can be applied to both applications and resources. Once built, a [`Cors`]
//! builder can be used as an argument for Actix Web's `App::wrap()`, `Scope::wrap()`, or
//! `Resource::wrap()` methods.
//! This middleware can be applied to both applications and resources. Once built, a
//! [`Cors`] builder can be used as an argument for Actix Web's `App::wrap()`,
//! `Scope::wrap()`, or `Resource::wrap()` methods.
//!
//! This CORS middleware automatically handles `OPTIONS` preflight requests.
//!
//! # Crate Features
//! - `draft-private-network-access`: ⚠️ Unstable. Adds opt-in support for the [Private Network
//! Access] spec extensions. This feature is unstable since it will follow breaking changes in the
//! draft spec until it is finalized.
//!
//! # Example
//! ```no_run
//! use actix_cors::Cors;
@ -25,7 +20,7 @@
//! async fn main() -> std::io::Result<()> {
//! HttpServer::new(|| {
//! let cors = Cors::default()
//! .allowed_origin("https://www.rust-lang.org")
//! .allowed_origin("https://www.rust-lang.org/")
//! .allowed_origin_fn(|origin, _req_head| {
//! origin.as_bytes().ends_with(b".rust-lang.org")
//! })
@ -45,14 +40,12 @@
//! Ok(())
//! }
//! ```
//!
//! [Private Network Access]: https://wicg.github.io/private-network-access
#![forbid(unsafe_code)]
#![deny(rust_2018_idioms, nonstandard_style)]
#![warn(future_incompatible, missing_docs, missing_debug_implementations)]
#![doc(html_logo_url = "https://actix.rs/img/logo.png")]
#![doc(html_favicon_url = "https://actix.rs/favicon.ico")]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
mod all_or_some;
mod builder;
@ -60,8 +53,8 @@ mod error;
mod inner;
mod middleware;
use crate::{
all_or_some::AllOrSome,
inner::{Inner, OriginFn},
};
pub use crate::{builder::Cors, error::CorsError, middleware::CorsMiddleware};
use all_or_some::AllOrSome;
pub use builder::Cors;
pub use error::CorsError;
use inner::{Inner, OriginFn};
pub use middleware::CorsMiddleware;

View File

@ -3,7 +3,7 @@ use std::{collections::HashSet, rc::Rc};
use actix_utils::future::ok;
use actix_web::{
body::{EitherBody, MessageBody},
dev::{forward_ready, Service, ServiceRequest, ServiceResponse},
dev::{Service, ServiceRequest, ServiceResponse},
http::{
header::{self, HeaderValue},
Method,
@ -13,11 +13,7 @@ use actix_web::{
use futures_util::future::{FutureExt as _, LocalBoxFuture};
use log::debug;
use crate::{
builder::intersperse_header_values,
inner::{add_vary_header, header_value_try_into_method},
AllOrSome, CorsError, Inner,
};
use crate::{builder::intersperse_header_values, inner::add_vary_header, AllOrSome, Inner};
/// Service wrapper for Cross-Origin Resource Sharing support.
///
@ -31,43 +27,10 @@ pub struct CorsMiddleware<S> {
}
impl<S> CorsMiddleware<S> {
/// Returns true if request is `OPTIONS` and contains an `Access-Control-Request-Method` header.
fn is_request_preflight(req: &ServiceRequest) -> bool {
// check request method is OPTIONS
if req.method() != Method::OPTIONS {
return false;
}
// check follow-up request method is present and valid
if req
.headers()
.get(header::ACCESS_CONTROL_REQUEST_METHOD)
.and_then(header_value_try_into_method)
.is_none()
{
return false;
}
true
}
/// Validates preflight request headers against configuration and constructs preflight response.
///
/// Checks:
/// - `Origin` header is acceptable;
/// - `Access-Control-Request-Method` header is acceptable;
/// - `Access-Control-Request-Headers` header is acceptable.
fn handle_preflight(&self, req: ServiceRequest) -> ServiceResponse {
let inner = Rc::clone(&self.inner);
match inner.validate_origin(req.head()) {
Ok(true) => {}
Ok(false) => return req.error_response(CorsError::OriginNotAllowed),
Err(err) => return req.error_response(err),
};
fn handle_preflight(inner: &Inner, req: ServiceRequest) -> ServiceResponse {
if let Err(err) = inner
.validate_allowed_method(req.head())
.validate_origin(req.head())
.and_then(|_| inner.validate_allowed_method(req.head()))
.and_then(|_| inner.validate_allowed_headers(req.head()))
{
return req.error_response(err);
@ -93,18 +56,6 @@ impl<S> CorsMiddleware<S> {
res.insert_header((header::ACCESS_CONTROL_ALLOW_HEADERS, headers.clone()));
}
#[cfg(feature = "draft-private-network-access")]
if inner.allow_private_network_access
&& req
.headers()
.contains_key("access-control-request-private-network")
{
res.insert_header((
header::HeaderName::from_static("access-control-allow-private-network"),
HeaderValue::from_static("true"),
));
}
if inner.supports_credentials {
res.insert_header((
header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
@ -117,25 +68,16 @@ impl<S> CorsMiddleware<S> {
}
let mut res = res.finish();
if inner.vary_header {
add_vary_header(res.headers_mut());
}
req.into_response(res)
}
fn augment_response<B>(
inner: &Inner,
origin_allowed: bool,
mut res: ServiceResponse<B>,
) -> ServiceResponse<B> {
if origin_allowed {
fn augment_response<B>(inner: &Inner, mut res: ServiceResponse<B>) -> ServiceResponse<B> {
if let Some(origin) = inner.access_control_allow_origin(res.request().head()) {
res.headers_mut()
.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin);
};
}
if let Some(ref expose) = inner.expose_headers_baked {
log::trace!("exposing selected headers: {:?}", expose);
@ -144,11 +86,13 @@ impl<S> CorsMiddleware<S> {
.insert(header::ACCESS_CONTROL_EXPOSE_HEADERS, expose.clone());
} else if matches!(inner.expose_headers, AllOrSome::All) {
// intersperse_header_values requires that argument is non-empty
if !res.headers().is_empty() {
if !res.request().headers().is_empty() {
// extract header names from request
let expose_all_request_headers = res
.request()
.headers()
.keys()
.into_iter()
.map(|name| name.as_str())
.collect::<HashSet<_>>();
@ -173,19 +117,6 @@ impl<S> CorsMiddleware<S> {
);
}
#[cfg(feature = "draft-private-network-access")]
if inner.allow_private_network_access
&& res
.request()
.headers()
.contains_key("access-control-request-private-network")
{
res.headers_mut().insert(
header::HeaderName::from_static("access-control-allow-private-network"),
HeaderValue::from_static("true"),
);
}
if inner.vary_header {
add_vary_header(res.headers_mut());
}
@ -205,40 +136,34 @@ where
type Error = Error;
type Future = LocalBoxFuture<'static, Result<ServiceResponse<EitherBody<B>>, Error>>;
forward_ready!(service);
actix_service::forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let origin = req.headers().get(header::ORIGIN);
if self.inner.preflight && req.method() == Method::OPTIONS {
let inner = Rc::clone(&self.inner);
let res = Self::handle_preflight(&inner, req);
ok(res.map_into_right_body()).boxed_local()
} else {
let origin = req.headers().get(header::ORIGIN).cloned();
// handle preflight requests
if self.inner.preflight && Self::is_request_preflight(&req) {
let res = self.handle_preflight(req);
return ok(res.map_into_right_body()).boxed_local();
}
// only check actual requests with a origin header
let origin_allowed = match (origin, self.inner.validate_origin(req.head())) {
(None, _) => false,
(_, Ok(origin_allowed)) => origin_allowed,
(_, Err(err)) => {
if origin.is_some() {
// Only check requests with a origin header.
if let Err(err) = self.inner.validate_origin(req.head()) {
debug!("origin validation failed; inner service is not called");
let mut res = req.error_response(err);
if self.inner.vary_header {
add_vary_header(res.headers_mut());
return ok(req.error_response(err).map_into_right_body()).boxed_local();
}
return ok(res.map_into_right_body()).boxed_local();
}
};
let inner = Rc::clone(&self.inner);
let fut = self.service.call(req);
Box::pin(async move {
async move {
let res = fut.await;
Ok(Self::augment_response(&inner, origin_allowed, res?).map_into_left_body())
})
Ok(Self::augment_response(&inner, res?).map_into_left_body())
}
.boxed_local()
}
}
}
@ -268,6 +193,7 @@ mod tests {
.allow_any_origin()
.allowed_origin_fn(|origin, req_head| {
assert_eq!(&origin, req_head.headers.get(header::ORIGIN).unwrap());
req_head.headers().contains_key(header::DNT)
})
.new_transform(test::ok_service())

View File

@ -1,7 +1,7 @@
use actix_cors::Cors;
use actix_service::fn_service;
use actix_utils::future::ok;
use actix_web::{
dev::{fn_service, ServiceRequest, Transform},
dev::{ServiceRequest, Transform},
http::{
header::{self, HeaderValue},
Method, StatusCode,
@ -11,6 +11,8 @@ use actix_web::{
};
use regex::bytes::Regex;
use actix_cors::Cors;
fn val_as_str(val: &HeaderValue) -> &str {
val.to_str().unwrap()
}
@ -264,16 +266,10 @@ async fn test_response() {
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
.map(HeaderValue::as_bytes)
);
#[cfg(not(feature = "draft-private-network-access"))]
assert_eq!(
resp.headers().get(header::VARY).map(HeaderValue::as_bytes),
Some(&b"Origin, Access-Control-Request-Method, Access-Control-Request-Headers"[..]),
);
#[cfg(feature = "draft-private-network-access")]
assert_eq!(
resp.headers().get(header::VARY).map(HeaderValue::as_bytes),
Some(&b"Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network"[..]),
);
#[allow(clippy::needless_collect)]
{
@ -317,18 +313,9 @@ async fn test_response() {
.method(Method::OPTIONS)
.to_srv_request();
let resp = test::call_service(&cors, req).await;
#[cfg(not(feature = "draft-private-network-access"))]
assert_eq!(
resp.headers()
.get(header::VARY)
.map(HeaderValue::as_bytes)
.unwrap(),
b"Accept, Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
);
#[cfg(feature = "draft-private-network-access")]
assert_eq!(
resp.headers().get(header::VARY).map(HeaderValue::as_bytes).unwrap(),
b"Accept, Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network",
resp.headers().get(header::VARY).map(HeaderValue::as_bytes),
Some(&b"Accept, Origin, Access-Control-Request-Method, Access-Control-Request-Headers"[..]),
);
let cors = Cors::default()
@ -369,55 +356,6 @@ async fn test_validate_origin() {
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn test_blocks_mismatched_origin_by_default() {
let cors = Cors::default()
.allowed_origin("https://www.example.com")
.new_transform(test::ok_service())
.await
.unwrap();
let req = TestRequest::get()
.insert_header(("Origin", "https://www.example.test"))
.to_srv_request();
let res = test::call_service(&cors, req).await;
assert_eq!(res.status(), StatusCode::OK);
assert!(!res
.headers()
.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN));
assert!(!res
.headers()
.contains_key(header::ACCESS_CONTROL_ALLOW_METHODS));
}
#[actix_web::test]
async fn test_mismatched_origin_block_turned_off() {
let cors = Cors::default()
.allow_any_method()
.allowed_origin("https://www.example.com")
.block_on_origin_mismatch(false)
.new_transform(test::ok_service())
.await
.unwrap();
let req = TestRequest::default()
.method(Method::OPTIONS)
.insert_header(("Origin", "https://wrong.com"))
.insert_header(("Access-Control-Request-Method", "POST"))
.to_srv_request();
let res = test::call_service(&cors, req).await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
assert_eq!(res.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN), None);
let req = TestRequest::get()
.insert_header(("Origin", "https://wrong.com"))
.to_srv_request();
let res = test::call_service(&cors, req).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN), None);
}
#[actix_web::test]
async fn test_no_origin_response() {
let cors = Cors::permissive()
@ -479,7 +417,6 @@ async fn vary_header_on_all_handled_responses() {
assert!(resp
.headers()
.contains_key(header::ACCESS_CONTROL_ALLOW_METHODS));
#[cfg(not(feature = "draft-private-network-access"))]
assert_eq!(
resp.headers()
.get(header::VARY)
@ -488,15 +425,6 @@ async fn vary_header_on_all_handled_responses() {
.unwrap(),
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
);
#[cfg(feature = "draft-private-network-access")]
assert_eq!(
resp.headers()
.get(header::VARY)
.expect("response should have Vary header")
.to_str()
.unwrap(),
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network",
);
// follow-up regular request
let req = TestRequest::default()
@ -505,7 +433,6 @@ async fn vary_header_on_all_handled_responses() {
.to_srv_request();
let resp = test::call_service(&cors, req).await;
assert_eq!(resp.status(), StatusCode::OK);
#[cfg(not(feature = "draft-private-network-access"))]
assert_eq!(
resp.headers()
.get(header::VARY)
@ -514,15 +441,6 @@ async fn vary_header_on_all_handled_responses() {
.unwrap(),
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
);
#[cfg(feature = "draft-private-network-access")]
assert_eq!(
resp.headers()
.get(header::VARY)
.expect("response should have Vary header")
.to_str()
.unwrap(),
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network",
);
let cors = Cors::default()
.allow_any_method()
@ -530,44 +448,26 @@ async fn vary_header_on_all_handled_responses() {
.await
.unwrap();
// regular request OK with no CORS response headers
// regular request bad origin
let req = TestRequest::default()
.method(Method::PUT)
.insert_header((header::ORIGIN, "https://www.example.com"))
.to_srv_request();
let res = test::call_service(&cors, req).await;
assert_eq!(res.status(), StatusCode::OK);
assert!(!res
.headers()
.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN));
assert!(!res
.headers()
.contains_key(header::ACCESS_CONTROL_ALLOW_METHODS));
#[cfg(not(feature = "draft-private-network-access"))]
let resp = test::call_service(&cors, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
assert_eq!(
res.headers()
resp.headers()
.get(header::VARY)
.expect("response should have Vary header")
.to_str()
.unwrap(),
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
);
#[cfg(feature = "draft-private-network-access")]
assert_eq!(
res.headers()
.get(header::VARY)
.expect("response should have Vary header")
.to_str()
.unwrap(),
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network",
);
// regular request no origin
let req = TestRequest::default().method(Method::PUT).to_srv_request();
let resp = test::call_service(&cors, req).await;
assert_eq!(resp.status(), StatusCode::OK);
#[cfg(not(feature = "draft-private-network-access"))]
assert_eq!(
resp.headers()
.get(header::VARY)
@ -576,15 +476,6 @@ async fn vary_header_on_all_handled_responses() {
.unwrap(),
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
);
#[cfg(feature = "draft-private-network-access")]
assert_eq!(
resp.headers()
.get(header::VARY)
.expect("response should have Vary header")
.to_str()
.unwrap(),
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network",
);
}
#[actix_web::test]
@ -611,15 +502,7 @@ async fn test_allow_any_origin_any_method_any_header() {
#[actix_web::test]
async fn expose_all_request_header_values() {
let cors = Cors::permissive()
.new_transform(fn_service(|req: ServiceRequest| async move {
let res = req.into_response(
HttpResponse::Ok()
.insert_header((header::CONTENT_DISPOSITION, "test disposition"))
.finish(),
);
Ok(res)
}))
.new_transform(test::ok_service())
.await
.unwrap();
@ -627,56 +510,20 @@ async fn expose_all_request_header_values() {
.insert_header((header::ORIGIN, "https://www.example.com"))
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
.insert_header((header::ACCESS_CONTROL_REQUEST_HEADERS, "content-type"))
.insert_header(("X-XSRF-TOKEN", "xsrf-token"))
.to_srv_request();
let res = test::call_service(&cors, req).await;
let resp = test::call_service(&cors, req).await;
let cd_hdr = res
assert!(resp
.headers()
.contains_key(header::ACCESS_CONTROL_EXPOSE_HEADERS));
assert!(resp
.headers()
.get(header::ACCESS_CONTROL_EXPOSE_HEADERS)
.unwrap()
.to_str()
.unwrap();
assert!(cd_hdr.contains("content-disposition"));
assert!(cd_hdr.contains("access-control-allow-origin"));
}
#[cfg(feature = "draft-private-network-access")]
#[actix_web::test]
async fn private_network_access() {
let cors = Cors::permissive()
.allowed_origin("https://public.site")
.allow_private_network_access()
.new_transform(fn_service(|req: ServiceRequest| async move {
let res = req.into_response(
HttpResponse::Ok()
.insert_header((header::CONTENT_DISPOSITION, "test disposition"))
.finish(),
);
Ok(res)
}))
.await
.unwrap();
let req = TestRequest::default()
.insert_header((header::ORIGIN, "https://public.site"))
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
.insert_header((header::ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"))
.to_srv_request();
let res = test::call_service(&cors, req).await;
assert!(res.headers().contains_key("access-control-allow-origin"));
let req = TestRequest::default()
.insert_header((header::ORIGIN, "https://public.site"))
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
.insert_header((header::ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"))
.insert_header(("Access-Control-Request-Private-Network", "true"))
.to_srv_request();
let res = test::call_service(&cors, req).await;
assert!(res.headers().contains_key("access-control-allow-origin"));
assert!(res
.headers()
.contains_key("access-control-allow-private-network"));
.unwrap()
.contains("xsrf-token"));
}

View File

@ -1,126 +1,58 @@
# Changes
## Unreleased
## Unreleased - 2021-xx-xx
## 0.8.0
- Update `actix-session` dependency to `0.10`.
## 0.7.1
- Add `IdentityMiddlewareBuilder::{id_key, last_visit_unix_timestamp_key, login_unix_timestamp_key}()` methods for customizing keys used in session. Defaults remain the same as before.
## 0.7.0
- Update `actix-session` dependency to `0.9`.
- Minimum supported Rust version (MSRV) is now 1.75.
## 0.6.0
- Add `error` module.
- Replace use of `anyhow::Error` in return types with specific error types.
- Update `actix-session` dependency to `0.8`.
- Minimum supported Rust version (MSRV) is now 1.68.
## 0.5.2
- Fix visit deadline. [#263]
[#263]: https://github.com/actix/actix-extras/pull/263
## 0.5.1
- Remove unnecessary dependencies. [#259]
[#259]: https://github.com/actix/actix-extras/pull/259
## 0.5.0
`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
## 0.4.0 - 2022-03-01
- Update `actix-web` dependency to `4`.
## 0.4.0-beta.9
## 0.4.0-beta.9 - 2022-02-07
- Relax body type bounds on middleware impl. [#223]
- Update `actix-web` dependency to `4.0.0-rc.1`.
[#223]: https://github.com/actix/actix-extras/pull/223
## 0.4.0-beta.8
## 0.4.0-beta.8 - 2022-01-21
- No significant changes since `0.4.0-beta.7`.
## 0.4.0-beta.7
## 0.4.0-beta.7 - 2021-12-29
- Update `actix-web` dependency to `4.0.0.beta-18`. [#218]
- Minimum supported Rust version (MSRV) is now 1.54.
[#218]: https://github.com/actix/actix-extras/pull/218
## 0.4.0-beta.6
## 0.4.0-beta.6 - 2021-12-18
- Update `actix-web` dependency to `4.0.0.beta-15`. [#216]
[#216]: https://github.com/actix/actix-extras/pull/216
## 0.4.0-beta.5
## 0.4.0-beta.5 - 2021-12-12
- Update `actix-web` dependency to `4.0.0.beta-14`. [#209]
[#209]: https://github.com/actix/actix-extras/pull/209
## 0.4.0-beta.4
## 0.4.0-beta.4 - 2021-11-22
- No significant changes since `0.4.0-beta.3`.
## 0.4.0-beta.3
## 0.4.0-beta.3 - 2021-10-21
- Update `actix-web` dependency to v4.0.0-beta.10. [#203]
- Minimum supported Rust version (MSRV) is now 1.52.
[#203]: https://github.com/actix/actix-extras/pull/203
## 0.4.0-beta.2
## 0.4.0-beta.2 - 2021-06-27
- No notable changes.
## 0.4.0-beta.1
## 0.4.0-beta.1 - 2021-04-02
- Rename `CookieIdentityPolicy::{max_age => max_age_secs}`. [#168]
- Rename `CookieIdentityPolicy::{max_age_time => max_age}`. [#168]
- Update `actix-web` dependency to 4.0.0 beta.
@ -128,31 +60,31 @@ Changes:
[#168]: https://github.com/actix/actix-extras/pull/168
## 0.3.1
## 0.3.1 - 2020-09-20
- Add method to set `HttpOnly` flag on cookie identity. [#102]
[#102]: https://github.com/actix/actix-extras/pull/102
## 0.3.0
## 0.3.0 - 2020-09-11
- Update `actix-web` dependency to 3.0.0.
- Minimum supported Rust version (MSRV) is now 1.42.0.
## 0.3.0-alpha.1
## 0.3.0-alpha.1 - 2020-03-14
- Update the `time` dependency to 0.2.7
- Update the `actix-web` dependency to 3.0.0-alpha.1
- Minimize `futures` dependency
## 0.2.1
## 0.2.1 - 2020-01-10
- Fix panic with already borrowed: BorrowMutError #1263
## 0.2.0 - 2019-12-20
## 0.2.0 - 2019-12-20
- Use actix-web 2.0
## 0.1.0 - 2019-06-xx
## 0.1.0 - 2019-06-xx
- Move identity middleware to separate crate

View File

@ -1,41 +1,28 @@
[package]
name = "actix-identity"
version = "0.8.0"
authors = [
"Nikolay Kim <fafhrd91@gmail.com>",
"Luca Palmieri <rust@lpalmieri.com>",
]
description = "Identity management for Actix Web"
version = "0.4.0"
authors = ["Nikolay Kim <fafhrd91@gmail.com>"]
description = "Identity service for Actix Web"
keywords = ["actix", "auth", "identity", "web", "security"]
repository.workspace = true
homepage.workspace = true
license.workspace = true
edition.workspace = true
rust-version.workspace = true
homepage = "https://actix.rs"
repository = "https://github.com/actix/actix-extras.git"
license = "MIT OR Apache-2.0"
edition = "2018"
[package.metadata.docs.rs]
rustdoc-args = ["--cfg", "docsrs"]
all-features = true
[lib]
name = "actix_identity"
path = "src/lib.rs"
[dependencies]
actix-service = "2"
actix-session = "0.10"
actix-utils = "3"
actix-web = { version = "4", default-features = false, features = ["cookies", "secure-cookies"] }
derive_more = { version = "2", features = ["display", "error", "from"] }
futures-core = "0.3.17"
futures-util = { version = "0.3.7", default-features = false }
serde = { version = "1", features = ["derive"] }
tracing = { version = "0.1.30", default-features = false, features = ["log"] }
serde_json = "1"
time = "0.3"
[dev-dependencies]
actix-http = "3"
actix-web = { version = "4", default-features = false, features = ["macros", "cookies", "secure-cookies"] }
actix-session = { version = "0.10", features = ["redis-session", "cookie-session"] }
env_logger = "0.11"
reqwest = { version = "0.12", default-features = false, features = ["cookies", "json"] }
uuid = { version = "1", features = ["v4"] }
[lints]
workspace = true
actix-http = "3.0.0-rc.1"
actix-web = { version = "4", default_features = false, features = ["macros", "cookies", "secure-cookies"] }

View File

@ -1,106 +1,13 @@
# actix-identity
> Identity management for Actix Web.
<!-- prettier-ignore-start -->
> Identity service for actix-web framework.
[![crates.io](https://img.shields.io/crates/v/actix-identity?label=latest)](https://crates.io/crates/actix-identity)
[![Documentation](https://docs.rs/actix-identity/badge.svg?version=0.8.0)](https://docs.rs/actix-identity/0.8.0)
[![Documentation](https://docs.rs/actix-identity/badge.svg?version=0.4.0)](https://docs.rs/actix-identity/0.4.0)
![Apache 2.0 or MIT licensed](https://img.shields.io/crates/l/actix-identity)
[![Dependency Status](https://deps.rs/crate/actix-identity/0.8.0/status.svg)](https://deps.rs/crate/actix-identity/0.8.0)
[![Dependency Status](https://deps.rs/crate/actix-identity/0.4.0/status.svg)](https://deps.rs/crate/actix-identity/0.4.0)
<!-- prettier-ignore-end -->
## Documentation & community resources
<!-- cargo-rdme start -->
Identity management for Actix Web.
`actix-identity` can be used to track identity of a user across multiple requests. It is built on top of HTTP sessions, via [`actix-session`](https://docs.rs/actix-session).
## Getting started
To start using identity management in your Actix Web application you must register [`IdentityMiddleware`] and `SessionMiddleware` as middleware on your `App`:
```rust
use actix_web::{cookie::Key, App, HttpServer, HttpResponse};
use actix_identity::IdentityMiddleware;
use actix_session::{storage::RedisSessionStore, SessionMiddleware};
#[actix_web::main]
async fn main() {
// When using `Key::generate()` it is important to initialize outside of the
// `HttpServer::new` closure. When deployed the secret key should be read from a
// configuration file or environment variables.
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 [...]
})
}
```
User identities can be created, accessed and destroyed using the [`Identity`] extractor in your request handlers:
```rust
use actix_web::{get, post, HttpResponse, Responder, HttpRequest, HttpMessage};
use actix_identity::Identity;
use actix_session::storage::RedisSessionStore;
#[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.
// [...]
// attach a verified user identity to the active session
Identity::login(&request.extensions(), "User1".into()).unwrap();
HttpResponse::Ok()
}
#[post("/logout")]
async fn logout(user: Option<Identity>) -> impl Responder {
if let Some(user) = user {
user.logout();
}
HttpResponse::Ok()
}
```
## 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
<!-- cargo-rdme end -->
* [API Documentation](https://docs.rs/actix-identity)
* Minimum Supported Rust Version (MSRV): 1.54

View File

@ -1,93 +0,0 @@
//! 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, time::Duration};
use actix_identity::{Identity, IdentityMiddleware};
use actix_session::{config::PersistentSession, 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();
let expiration = Duration::from_secs(24 * 60 * 60);
HttpServer::new(move || {
let session_mw =
SessionMiddleware::builder(CookieSessionStore::default(), secret_key.clone())
// disable secure cookie for local testing
.cookie_secure(false)
// Set a ttl for the cookie if the identity should live longer than the user session
.session_lifecycle(
PersistentSession::default().session_ttl(expiration.try_into().unwrap()),
)
.build();
let identity_mw = IdentityMiddleware::builder()
.visit_deadline(Some(expiration))
.build();
App::new()
// Install the identity framework first.
.wrap(identity_mw)
// 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

@ -1,125 +0,0 @@
//! 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>,
pub(crate) id_key: &'static str,
pub(crate) last_visit_unix_timestamp_key: &'static str,
pub(crate) login_unix_timestamp_key: &'static str,
}
impl Default for Configuration {
fn default() -> Self {
Self {
on_logout: LogoutBehaviour::PurgeSession,
login_deadline: None,
visit_deadline: None,
id_key: "actix_identity.user_id",
last_visit_unix_timestamp_key: "actix_identity.last_visited_at",
login_unix_timestamp_key: "actix_identity.logged_in_at",
}
}
}
/// `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(),
}
}
/// Set a custom key to identify the user in the session.
pub fn id_key(mut self, key: &'static str) -> Self {
self.configuration.id_key = key;
self
}
/// Set a custom key to store the last visited unix timestamp.
pub fn last_visit_unix_timestamp_key(mut self, key: &'static str) -> Self {
self.configuration.last_visit_unix_timestamp_key = key;
self
}
/// Set a custom key to store the login unix timestamp.
pub fn login_unix_timestamp_key(mut self, key: &'static str) -> Self {
self.configuration.login_unix_timestamp_key = key;
self
}
/// 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

@ -0,0 +1,829 @@
use std::{rc::Rc, time::SystemTime};
use actix_utils::future::{ready, Ready};
use serde::{Deserialize, Serialize};
use time::Duration;
use actix_web::{
cookie::{Cookie, CookieJar, Key, SameSite},
dev::{ServiceRequest, ServiceResponse},
error::{Error, Result},
http::header::{self, HeaderValue},
HttpMessage,
};
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,70 +0,0 @@
//! Failure modes of identity operations.
use actix_session::{SessionGetError, SessionInsertError};
use actix_web::{cookie::time::error::ComponentRange, http::StatusCode, ResponseError};
use derive_more::derive::{Display, Error, From};
/// Error that can occur during login attempts.
#[derive(Debug, Display, Error, From)]
#[display("{_0}")]
pub struct LoginError(SessionInsertError);
impl ResponseError for LoginError {
fn status_code(&self) -> StatusCode {
StatusCode::UNAUTHORIZED
}
}
/// Error encountered when working with a session that has expired.
#[derive(Debug, Display, Error)]
#[display("The given session has expired and is no longer valid")]
pub struct SessionExpiryError(#[error(not(source))] pub(crate) ComponentRange);
/// The identity information has been lost.
///
/// Seeing this error in user code indicates a bug in actix-identity.
#[derive(Debug, Display, Error)]
#[display(
"The identity information in the current session has disappeared after having been \
successfully validated. This is likely to be a bug."
)]
#[non_exhaustive]
pub struct LostIdentityError;
/// There is no identity information attached to the current session.
#[derive(Debug, Display, Error)]
#[display("There is no identity information attached to the current session")]
#[non_exhaustive]
pub struct MissingIdentityError;
/// Errors that can occur while retrieving an identity.
#[derive(Debug, Display, Error, From)]
#[non_exhaustive]
pub enum GetIdentityError {
/// The session has expired.
#[display("{_0}")]
SessionExpiryError(SessionExpiryError),
/// No identity is found in a session.
#[display("{_0}")]
MissingIdentityError(MissingIdentityError),
/// Failed to accessing the session store.
#[display("{_0}")]
SessionGetError(SessionGetError),
/// Identity info was lost after being validated.
///
/// Seeing this error indicates a bug in actix-identity.
#[display("{_0}")]
LostIdentityError(LostIdentityError),
}
impl ResponseError for GetIdentityError {
fn status_code(&self) -> StatusCode {
match self {
Self::LostIdentityError(_) => StatusCode::INTERNAL_SERVER_ERROR,
_ => StatusCode::UNAUTHORIZED,
}
}
}

View File

@ -1,254 +1,89 @@
use actix_session::Session;
use actix_utils::future::{ready, Ready};
use actix_web::{
cookie::time::OffsetDateTime,
dev::{Extensions, Payload},
http::StatusCode,
Error, FromRequest, HttpMessage, HttpRequest, HttpResponse,
Error, FromRequest, HttpMessage as _, HttpRequest,
};
use crate::{
config::LogoutBehaviour,
error::{
GetIdentityError, LoginError, LostIdentityError, MissingIdentityError, SessionExpiryError,
},
};
pub(crate) struct IdentityItem {
pub(crate) id: Option<String>,
pub(crate) changed: bool,
}
/// A verified user identity. It can be used as a request extractor.
/// The extractor type to obtain your identity from a request.
///
/// 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::{
/// get, post, Responder, HttpRequest, HttpMessage, HttpResponse
/// };
/// use actix_web::*;
/// use actix_identity::Identity;
///
/// #[get("/")]
/// async fn index(user: Option<Identity>) -> impl Responder {
/// if let Some(user) = user {
/// format!("Welcome! {}", user.id().unwrap())
/// async fn index(id: Identity) -> impl Responder {
/// // access request identity
/// if let Some(id) = id.identity() {
/// format!("Welcome! {}", id)
/// } else {
/// "Welcome Anonymous!".to_owned()
/// }
/// }
///
/// #[post("/login")]
/// async fn login(request: HttpRequest) -> impl Responder {
/// Identity::login(&request.extensions(), "User1".into());
/// async fn login(id: Identity) -> impl Responder {
/// // remember identity
/// id.remember("User1".to_owned());
///
/// HttpResponse::Ok()
/// }
///
/// #[post("/logout")]
/// async fn logout(user: Identity) -> impl Responder {
/// user.logout();
/// async fn logout(id: Identity) -> impl Responder {
/// // remove identity
/// id.forget();
///
/// HttpResponse::Ok()
/// }
/// ```
///
/// # 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::{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()
/// }
/// }
/// ```
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,
pub(crate) id_key: &'static str,
pub(crate) last_visit_unix_timestamp_key: &'static str,
pub(crate) login_unix_timestamp_key: &'static str,
}
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, GetIdentityError> {
self.session
.get::<String>(self.id_key)?
.ok_or_else(|| MissingIdentityError.into())
}
}
pub struct Identity(HttpRequest);
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, GetIdentityError> {
self.0
.session
.get(self.0.id_key)?
.ok_or_else(|| LostIdentityError.into())
/// 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())
}
/// 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, LoginError> {
let inner = IdentityInner::extract(ext);
inner.session.insert(inner.id_key, id)?;
let now = OffsetDateTime::now_utc().unix_timestamp();
if inner.is_login_deadline_enabled {
inner.session.insert(inner.login_unix_timestamp_key, now)?;
}
if inner.is_visit_deadline_enabled {
inner
.session
.insert(inner.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(self.0.id_key);
if self.0.is_login_deadline_enabled {
self.0.session.remove(self.0.login_unix_timestamp_key);
}
if self.0.is_visit_deadline_enabled {
self.0.session.remove(self.0.last_visit_unix_timestamp_key);
}
}
/// 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;
}
}
pub(crate) fn extract(ext: &Extensions) -> Result<Self, GetIdentityError> {
let inner = IdentityInner::extract(ext);
inner.get_identity()?;
Ok(Self(inner))
/// 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 logged_at(&self) -> Result<Option<OffsetDateTime>, GetIdentityError> {
Ok(self
.0
.session
.get(self.0.login_unix_timestamp_key)?
.map(OffsetDateTime::from_unix_timestamp)
.transpose()
.map_err(SessionExpiryError)?)
}
pub(crate) fn last_visited_at(&self) -> Result<Option<OffsetDateTime>, GetIdentityError> {
Ok(self
.0
.session
.get(self.0.last_visit_unix_timestamp_key)?
.map(OffsetDateTime::from_unix_timestamp)
.transpose()
.map_err(SessionExpiryError)?)
}
pub(crate) fn set_last_visited_at(&self) -> Result<(), LoginError> {
let now = OffsetDateTime::now_utc().unix_timestamp();
self.0
.session
.insert(self.0.last_visit_unix_timestamp_key, now)?;
Ok(())
pub(crate) fn get_identity(extensions: &Extensions) -> Option<String> {
let id = extensions.get::<IdentityItem>()?;
id.id.clone()
}
}
/// Extractor implementation for [`Identity`].
/// Extractor implementation for Identity type.
///
/// # Examples
/// ```
/// use actix_web::{get, Responder};
/// # use actix_web::*;
/// use actix_identity::Identity;
///
/// #[get("/")]
/// async fn index(user: Option<Identity>) -> impl Responder {
/// if let Some(user) = user {
/// format!("Welcome! {}", user.id().unwrap())
/// async fn index(id: Identity) -> impl Responder {
/// // access request identity
/// if let Some(id) = id.identity() {
/// format!("Welcome! {}", id)
/// } else {
/// "Welcome Anonymous!".to_owned()
/// }
@ -256,17 +91,10 @@ impl Identity {
/// ```
impl FromRequest for Identity {
type Error = Error;
type Future = Ready<Result<Self, Self::Error>>;
type Future = Ready<Result<Identity, Error>>;
#[inline]
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
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)
}))
ready(Ok(Identity(req.clone())))
}
}

View File

@ -1,27 +0,0 @@
use actix_web::{dev::ServiceRequest, guard::GuardContext, HttpMessage, HttpRequest};
use crate::{error::GetIdentityError, 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, GetIdentityError>;
}
impl IdentityExt for HttpRequest {
fn get_identity(&self) -> Result<Identity, GetIdentityError> {
Identity::extract(&self.extensions())
}
}
impl IdentityExt for ServiceRequest {
fn get_identity(&self) -> Result<Identity, GetIdentityError> {
Identity::extract(&self.extensions())
}
}
impl IdentityExt for GuardContext<'_> {
fn get_identity(&self) -> Result<Identity, GetIdentityError> {
Identity::extract(&self.req_data())
}
}

View File

@ -1,109 +1,157 @@
/*!
Identity management for Actix Web.
//! Opinionated request identity service for Actix Web apps.
//!
//! [`IdentityService`] middleware can be used with different policies types to store
//! identity information.
//!
//! A cookie based policy is provided. [`CookieIdentityPolicy`] uses cookies as identity storage.
//!
//! To access current request identity, use the [`Identity`] extractor.
//!
//! ```
//! use actix_web::*;
//! use actix_identity::{Identity, CookieIdentityPolicy, IdentityService};
//!
//! #[get("/")]
//! async fn index(id: Identity) -> String {
//! // access request identity
//! if let Some(id) = id.identity() {
//! format!("Welcome! {}", id)
//! } else {
//! "Welcome Anonymous!".to_owned()
//! }
//! }
//!
//! #[post("/login")]
//! async fn login(id: Identity) -> HttpResponse {
//! id.remember("User1".to_owned()); // <- remember identity
//! HttpResponse::Ok().finish()
//! }
//!
//! #[post("/logout")]
//! async fn logout(id: Identity) -> HttpResponse {
//! id.forget(); // <- remove identity
//! HttpResponse::Ok().finish()
//! }
//!
//! // create cookie identity backend
//! let policy = CookieIdentityPolicy::new(&[0; 32])
//! .name("auth-cookie")
//! .secure(false);
//!
//! let app = App::new()
//! // wrap policy into middleware identity middleware
//! .wrap(IdentityService::new(policy))
//! .service(services![index, login, logout]);
//! ```
`actix-identity` can be used to track identity of a user across multiple requests. It is built
on top of HTTP sessions, via [`actix-session`](https://docs.rs/actix-session).
#![deny(rust_2018_idioms, nonstandard_style)]
#![warn(future_incompatible)]
# Getting started
To start using identity management in your Actix Web application you must register
[`IdentityMiddleware`] and `SessionMiddleware` as middleware on your `App`:
use std::future::Future;
```no_run
# use actix_web::web;
use actix_web::{cookie::Key, App, HttpServer, HttpResponse};
use actix_identity::IdentityMiddleware;
use actix_session::{storage::RedisSessionStore, SessionMiddleware};
use actix_web::{
dev::{ServiceRequest, ServiceResponse},
Error, HttpMessage, Result,
};
#[actix_web::main]
async fn main() {
// When using `Key::generate()` it is important to initialize outside of the
// `HttpServer::new` closure. When deployed the secret key should be read from a
// configuration file or environment variables.
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()))
})
# ;
}
```
User identities can be created, accessed and destroyed using the [`Identity`] extractor in your
request handlers:
```no_run
use actix_web::{get, post, HttpResponse, Responder, HttpRequest, HttpMessage};
use actix_identity::Identity;
use actix_session::storage::RedisSessionStore;
#[get("/")]
async fn index(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.
// [...]
// attach a verified user identity to the active session
Identity::login(&request.extensions(), "User1".into()).unwrap();
HttpResponse::Ok()
}
#[post("/logout")]
async fn logout(user: Option<Identity>) -> impl Responder {
if let Some(user) = user {
user.logout();
}
HttpResponse::Ok()
}
```
# 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
*/
#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![doc(html_logo_url = "https://actix.rs/img/logo.png")]
#![doc(html_favicon_url = "https://actix.rs/favicon.ico")]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
pub mod config;
pub mod error;
mod cookie;
mod identity;
mod identity_ext;
mod middleware;
pub use self::{identity::Identity, identity_ext::IdentityExt, middleware::IdentityMiddleware};
pub use self::cookie::CookieIdentityPolicy;
pub use self::identity::Identity;
pub use self::middleware::IdentityService;
/// Identity policy.
pub trait IdentityPolicy: Sized + 'static {
/// The return type of the middleware
type Future: Future<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.
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,259 +1,171 @@
use std::rc::Rc;
use actix_session::SessionExt;
use actix_utils::future::{ready, Ready};
use actix_web::{
body::MessageBody,
cookie::time::{format_description::well_known::Rfc3339, OffsetDateTime},
body::{EitherBody, MessageBody},
dev::{Service, ServiceRequest, ServiceResponse, Transform},
Error, HttpMessage as _, Result,
Error, HttpMessage, Result,
};
use futures_core::future::LocalBoxFuture;
use futures_util::future::{FutureExt as _, LocalBoxFuture};
use crate::{
config::{Configuration, IdentityMiddlewareBuilder},
identity::IdentityInner,
Identity,
};
use crate::{identity::IdentityItem, IdentityPolicy};
/// Identity management middleware.
/// Request identity 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()))
/// })
/// # ;
/// }
/// ```
#[derive(Default, Clone)]
pub struct IdentityMiddleware {
configuration: Rc<Configuration>,
/// use actix_web::App;
/// use actix_identity::{CookieIdentityPolicy, IdentityService};
///
/// // create cookie identity backend
/// let policy = CookieIdentityPolicy::new(&[0; 32])
/// .name("auth-cookie")
/// .secure(false);
///
/// let app = App::new()
/// // wrap policy into identity middleware
/// .wrap(IdentityService::new(policy));
/// ```
pub struct IdentityService<T> {
backend: Rc<T>,
}
impl IdentityMiddleware {
pub(crate) fn new(configuration: Configuration) -> Self {
Self {
configuration: Rc::new(configuration),
impl<T> IdentityService<T> {
/// Create new identity service with specified backend.
pub fn new(backend: T) -> Self {
IdentityService {
backend: Rc::new(backend),
}
}
/// A fluent API to configure [`IdentityMiddleware`].
pub fn builder() -> IdentityMiddlewareBuilder {
IdentityMiddlewareBuilder::new()
}
}
impl<S, B> Transform<S, ServiceRequest> for IdentityMiddleware
impl<S, T, B> Transform<S, ServiceRequest> for IdentityService<T>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
T: IdentityPolicy,
B: MessageBody + 'static,
{
type Response = ServiceResponse<B>;
type Response = ServiceResponse<EitherBody<B>>;
type Error = Error;
type Transform = InnerIdentityMiddleware<S>;
type InitError = ();
type Transform = IdentityServiceMiddleware<S, T>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(InnerIdentityMiddleware {
ready(Ok(IdentityServiceMiddleware {
backend: self.backend.clone(),
service: Rc::new(service),
configuration: Rc::clone(&self.configuration),
}))
}
}
#[doc(hidden)]
pub struct InnerIdentityMiddleware<S> {
service: Rc<S>,
configuration: Rc<Configuration>,
pub struct IdentityServiceMiddleware<S, T> {
pub(crate) service: Rc<S>,
pub(crate) backend: Rc<T>,
}
impl<S> Clone for InnerIdentityMiddleware<S> {
impl<S, T> Clone for IdentityServiceMiddleware<S, T> {
fn clone(&self) -> Self {
Self {
backend: Rc::clone(&self.backend),
service: Rc::clone(&self.service),
configuration: Rc::clone(&self.configuration),
}
}
}
impl<S, B> Service<ServiceRequest> for InnerIdentityMiddleware<S>
impl<S, T, B> Service<ServiceRequest> for IdentityServiceMiddleware<S, T>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
T: IdentityPolicy,
B: MessageBody + 'static,
{
type Response = ServiceResponse<B>;
type Response = ServiceResponse<EitherBody<B>>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
actix_service::forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
fn call(&self, mut req: ServiceRequest) -> Self::Future {
let srv = Rc::clone(&self.service);
let configuration = Rc::clone(&self.configuration);
Box::pin(async move {
let identity_inner = IdentityInner {
session: req.get_session(),
logout_behaviour: configuration.on_logout.clone(),
is_login_deadline_enabled: configuration.login_deadline.is_some(),
is_visit_deadline_enabled: configuration.visit_deadline.is_some(),
id_key: configuration.id_key,
last_visit_unix_timestamp_key: configuration.last_visit_unix_timestamp_key,
login_unix_timestamp_key: configuration.login_unix_timestamp_key,
};
req.extensions_mut().insert(identity_inner);
enforce_policies(&req, &configuration);
srv.call(req).await
})
let backend = Rc::clone(&self.backend);
let fut = self.backend.from_request(&mut req);
async move {
match fut.await {
Ok(id) => {
req.extensions_mut()
.insert(IdentityItem { id, changed: false });
let mut res = srv.call(req).await?;
let id = res.request().extensions_mut().remove::<IdentityItem>();
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()
}
}
// easier to scan with returns where they are
// especially if the function body were to evolve in the future
#[allow(clippy::needless_return)]
fn enforce_policies(req: &ServiceRequest, configuration: &Configuration) {
let must_extract_identity =
configuration.login_deadline.is_some() || configuration.visit_deadline.is_some();
#[cfg(test)]
mod tests {
use std::{rc::Rc, time::Duration};
if !must_extract_identity {
return;
use actix_service::into_service;
use actix_web::{dev, error, test, Error, Result};
use super::*;
#[actix_web::test]
async fn test_borrowed_mut_error() {
use actix_utils::future::{ok, Ready};
use futures_util::future::lazy;
struct Ident;
impl IdentityPolicy for Ident {
type Future = Ready<Result<Option<String>, Error>>;
type ResponseFuture = Ready<Result<(), Error>>;
fn from_request(&self, _: &mut dev::ServiceRequest) -> Self::Future {
ok(Some("test".to_string()))
}
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;
fn to_response<B>(
&self,
_: Option<String>,
_: bool,
_: &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"))
})),
};
if let Some(login_deadline) = configuration.login_deadline {
if matches!(
enforce_login_deadline(&identity, login_deadline),
PolicyDecision::LogOut
) {
identity.logout();
return;
}
}
let srv2 = srv.clone();
let req = test::TestRequest::default().to_srv_request();
if let Some(visit_deadline) = configuration.visit_deadline {
if matches!(
enforce_visit_deadline(&identity, visit_deadline),
PolicyDecision::LogOut
) {
identity.logout();
return;
} else if let Err(err) = identity.set_last_visited_at() {
tracing::warn!(
error.display = %err,
error.debug = ?err,
"Failed to set the last visited timestamp on `Identity` for an incoming request."
);
}
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

@ -1,17 +0,0 @@
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

@ -1,212 +0,0 @@
use std::time::Duration;
use actix_identity::{config::LogoutBehaviour, IdentityMiddleware};
use reqwest::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 custom_keys_work_as_expected() {
let custom_id_key = "custom.user_id";
let custom_last_visited_key = "custom.last_visited_at";
let custom_logged_in_key = "custom.logged_in_at";
let app = TestApp::spawn_with_config(
IdentityMiddleware::builder()
.id_key(custom_id_key)
.last_visit_unix_timestamp_key(custom_last_visited_key)
.login_unix_timestamp_key(custom_logged_in_key),
);
let user_id = user_id();
let body = app.post_login(user_id.clone()).await;
assert_eq!(body.user_id, Some(user_id.clone()));
let response = app.get_identity_required().await;
assert!(response.status().is_success());
let response = app.post_logout().await;
assert!(response.status().is_success());
let response = app.get_identity_required().await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[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

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

View File

@ -1,187 +0,0 @@
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();
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(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EndpointResponse {
pub user_id: Option<String>,
pub counter: i32,
pub session_status: String,
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
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

@ -1,41 +0,0 @@
# Changes
## Unreleased
- Update `redis` dependency to `0.29`.
- Update `actix-session` dependency to `0.9`.
## 0.5.1
- No significant changes since `0.5.0`.
## 0.5.0
- Update `redis` dependency to `0.23`.
- Update `actix-session` dependency to `0.8`.
## 0.4.0
- Add `Builder::key_by` for setting a custom rate limit key function.
- Implement `Default` for `RateLimiter`.
- `RateLimiter` is marked `#[non_exhaustive]`; use `RateLimiter::default()` instead.
- In the middleware errors from the count function are matched and respond with `INTERNAL_SERVER_ERROR` if it's an unexpected error, instead of the default `TOO_MANY_REQUESTS`.
- Minimum supported Rust version (MSRV) is now 1.59 due to transitive `time` dependency.
## 0.3.0
- `Limiter::builder` now takes an `impl Into<String>`.
- Removed lifetime from `Builder`.
- Updated `actix-session` dependency to `0.7`.
## 0.2.0
- Update Actix Web dependency to v4 ecosystem.
- Update Tokio dependencies to v1 ecosystem.
- Rename `Limiter::{build => builder}()`.
- Rename `Builder::{finish => build}()`.
- Exceeding the rate limit now returns a 429 Too Many Requests response.
## 0.1.4
- Adopted into @actix org from <https://github.com/0xmad/actix-limitation>.

View File

@ -1,43 +0,0 @@
[package]
name = "actix-limitation"
version = "0.5.1"
authors = [
"0xmad <0xmad@users.noreply.github.com>",
"Rob Ede <robjtede@icloud.com>",
]
description = "Rate limiter using a fixed window counter for arbitrary keys, backed by Redis for Actix Web"
keywords = ["actix-web", "rate-api", "rate-limit", "limitation"]
categories = ["asynchronous", "web-programming"]
repository = "https://github.com/actix/actix-extras"
license.workspace = true
edition.workspace = true
rust-version.workspace = true
[package.metadata.docs.rs]
rustdoc-args = ["--cfg", "docsrs"]
all-features = true
[features]
default = ["session"]
session = ["actix-session"]
[dependencies]
actix-utils = "3"
actix-web = { version = "4", default-features = false, features = ["cookies"] }
chrono = "0.4"
derive_more = { version = "2", features = ["display", "error", "from"] }
log = "0.4"
redis = { version = "0.29", default-features = false, features = ["tokio-comp"] }
time = "0.3"
# session
actix-session = { version = "0.10", optional = true }
[dev-dependencies]
actix-web = "4"
static_assertions = "1"
uuid = { version = "1", features = ["v4"] }
[lints]
workspace = true

View File

@ -1,58 +0,0 @@
# actix-limitation
> Rate limiter using a fixed window counter for arbitrary keys, backed by Redis for Actix Web.
> Originally based on <https://github.com/fnichol/limitation>.
<!-- prettier-ignore-start -->
[![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.5.1)](https://docs.rs/actix-limitation/0.5.1)
![Apache 2.0 or MIT licensed](https://img.shields.io/crates/l/actix-limitation)
[![Dependency Status](https://deps.rs/crate/actix-limitation/0.5.1/status.svg)](https://deps.rs/crate/actix-limitation/0.5.1)
<!-- prettier-ignore-end -->
## Examples
```toml
[dependencies]
actix-web = "4"
actix-limitation = "0.5"
```
```rust
use actix_limitation::{Limiter, RateLimiter};
use actix_session::SessionExt as _;
use actix_web::{dev::ServiceRequest, get, web, App, HttpServer, Responder};
use std::{sync::Arc, time::Duration};
#[get("/{id}/{name}")]
async fn index(info: web::Path<(u32, String)>) -> impl Responder {
format!("Hello {}! id:{}", info.1, info.0)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let limiter = web::Data::new(
Limiter::builder("redis://127.0.0.1")
.key_by(|req: &ServiceRequest| {
req.get_session()
.get(&"session-id")
.unwrap_or_else(|_| req.cookie(&"rate-api-id").map(|c| c.to_string()))
})
.limit(5000)
.period(Duration::from_secs(3600)) // 60 minutes
.build()
.unwrap(),
);
HttpServer::new(move || {
App::new()
.wrap(RateLimiter::default())
.app_data(limiter.clone())
.service(index)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
```

View File

@ -1,171 +0,0 @@
use std::{borrow::Cow, sync::Arc, time::Duration};
#[cfg(feature = "session")]
use actix_session::SessionExt as _;
use actix_web::dev::ServiceRequest;
use redis::Client;
use crate::{errors::Error, GetArcBoxKeyFn, Limiter};
/// Rate limiter builder.
#[derive(Debug)]
pub struct Builder {
pub(crate) redis_url: String,
pub(crate) limit: usize,
pub(crate) period: Duration,
pub(crate) get_key_fn: Option<GetArcBoxKeyFn>,
pub(crate) cookie_name: Cow<'static, str>,
#[cfg(feature = "session")]
pub(crate) session_key: Cow<'static, str>,
}
impl Builder {
/// Set upper limit.
pub fn limit(&mut self, limit: usize) -> &mut Self {
self.limit = limit;
self
}
/// Set limit window/period.
pub fn period(&mut self, period: Duration) -> &mut Self {
self.period = period;
self
}
/// Sets rate limit key derivation function.
///
/// Should not be used in combination with `cookie_name` or `session_key` as they conflict.
pub fn key_by<F>(&mut self, resolver: F) -> &mut Self
where
F: Fn(&ServiceRequest) -> Option<String> + Send + Sync + 'static,
{
self.get_key_fn = Some(Arc::new(resolver));
self
}
/// Sets name of cookie to be sent.
///
/// This method should not be used in combination of `key_by` as they conflict.
#[deprecated = "Prefer `key_by`."]
pub fn cookie_name(&mut self, cookie_name: impl Into<Cow<'static, str>>) -> &mut Self {
if self.get_key_fn.is_some() {
panic!("This method should not be used in combination of get_key as they overwrite each other")
}
self.cookie_name = cookie_name.into();
self
}
/// Sets session key to be used in backend.
///
/// This method should not be used in combination of `key_by` as they conflict.
#[deprecated = "Prefer `key_by`."]
#[cfg(feature = "session")]
pub fn session_key(&mut self, session_key: impl Into<Cow<'static, str>>) -> &mut Self {
if self.get_key_fn.is_some() {
panic!("This method should not be used in combination of get_key as they overwrite each other")
}
self.session_key = session_key.into();
self
}
/// Finalizes and returns a `Limiter`.
///
/// Note that this method will connect to the Redis server to test its connection which is a
/// **synchronous** operation.
pub fn build(&mut self) -> Result<Limiter, Error> {
let get_key = if let Some(resolver) = self.get_key_fn.clone() {
resolver
} else {
let cookie_name = self.cookie_name.clone();
#[cfg(feature = "session")]
let session_key = self.session_key.clone();
let closure: GetArcBoxKeyFn = Arc::new(Box::new(move |req: &ServiceRequest| {
#[cfg(feature = "session")]
let res = req
.get_session()
.get(&session_key)
.unwrap_or_else(|_| req.cookie(&cookie_name).map(|c| c.to_string()));
#[cfg(not(feature = "session"))]
let res = req.cookie(&cookie_name).map(|c| c.to_string());
res
}));
closure
};
Ok(Limiter {
client: Client::open(self.redis_url.as_str())?,
limit: self.limit,
period: self.period,
get_key_fn: get_key,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_builder() {
let redis_url = "redis://127.0.0.1";
let period = Duration::from_secs(10);
let builder = Builder {
redis_url: redis_url.to_owned(),
limit: 100,
period,
get_key_fn: Some(Arc::new(|_| None)),
cookie_name: Cow::Owned("session".to_string()),
#[cfg(feature = "session")]
session_key: Cow::Owned("rate-api".to_string()),
};
assert_eq!(builder.redis_url, redis_url);
assert_eq!(builder.limit, 100);
assert_eq!(builder.period, period);
#[cfg(feature = "session")]
assert_eq!(builder.session_key, "rate-api");
assert_eq!(builder.cookie_name, "session");
}
#[test]
fn test_create_limiter() {
let redis_url = "redis://127.0.0.1";
let period = Duration::from_secs(20);
let mut builder = Builder {
redis_url: redis_url.to_owned(),
limit: 100,
period: Duration::from_secs(10),
get_key_fn: Some(Arc::new(|_| None)),
cookie_name: Cow::Borrowed("sid"),
#[cfg(feature = "session")]
session_key: Cow::Borrowed("key"),
};
let limiter = builder.limit(200).period(period).build().unwrap();
assert_eq!(limiter.limit, 200);
assert_eq!(limiter.period, period);
}
#[test]
#[should_panic = "Redis URL did not parse"]
fn test_create_limiter_error() {
let redis_url = "127.0.0.1";
let period = Duration::from_secs(20);
let mut builder = Builder {
redis_url: redis_url.to_owned(),
limit: 100,
period: Duration::from_secs(10),
get_key_fn: Some(Arc::new(|_| None)),
cookie_name: Cow::Borrowed("sid"),
#[cfg(feature = "session")]
session_key: Cow::Borrowed("key"),
};
builder.limit(200).period(period).build().unwrap();
}
}

View File

@ -1,42 +0,0 @@
use derive_more::derive::{Display, Error, From};
use crate::status::Status;
/// Failure modes of the rate limiter.
#[derive(Debug, Display, Error, From)]
pub enum Error {
/// Redis client failed to connect or run a query.
#[display("Redis client failed to connect or run a query")]
Client(redis::RedisError),
/// Limit is exceeded for a key.
#[display("Limit is exceeded for a key")]
#[from(ignore)]
LimitExceeded(#[error(not(source))] Status),
/// Time conversion failed.
#[display("Time conversion failed")]
Time(time::error::ComponentRange),
/// Generic error.
#[display("Generic error")]
#[from(ignore)]
Other(#[error(not(source))] String),
}
#[cfg(test)]
mod tests {
use super::*;
static_assertions::assert_impl_all! {
Error:
From<redis::RedisError>,
From<time::error::ComponentRange>,
}
static_assertions::assert_not_impl_any! {
Error:
From<String>,
From<Status>,
}
}

View File

@ -1,179 +0,0 @@
//! Rate limiter using a fixed window counter for arbitrary keys, backed by Redis for Actix Web.
//!
//! ```toml
//! [dependencies]
//! actix-web = "4"
#![doc = concat!("actix-limitation = \"", env!("CARGO_PKG_VERSION_MAJOR"), ".", env!("CARGO_PKG_VERSION_MINOR"),"\"")]
//! ```
//!
//! ```no_run
//! use std::{sync::Arc, time::Duration};
//! use actix_web::{dev::ServiceRequest, get, web, App, HttpServer, Responder};
//! use actix_session::SessionExt as _;
//! use actix_limitation::{Limiter, RateLimiter};
//!
//! #[get("/{id}/{name}")]
//! async fn index(info: web::Path<(u32, String)>) -> impl Responder {
//! format!("Hello {}! id:{}", info.1, info.0)
//! }
//!
//! #[actix_web::main]
//! async fn main() -> std::io::Result<()> {
//! let limiter = web::Data::new(
//! Limiter::builder("redis://127.0.0.1")
//! .key_by(|req: &ServiceRequest| {
//! req.get_session()
//! .get(&"session-id")
//! .unwrap_or_else(|_| req.cookie(&"rate-api-id").map(|c| c.to_string()))
//! })
//! .limit(5000)
//! .period(Duration::from_secs(3600)) // 60 minutes
//! .build()
//! .unwrap(),
//! );
//!
//! HttpServer::new(move || {
//! App::new()
//! .wrap(RateLimiter::default())
//! .app_data(limiter.clone())
//! .service(index)
//! })
//! .bind(("127.0.0.1", 8080))?
//! .run()
//! .await
//! }
//! ```
#![forbid(unsafe_code)]
#![warn(missing_docs, missing_debug_implementations)]
#![doc(html_logo_url = "https://actix.rs/img/logo.png")]
#![doc(html_favicon_url = "https://actix.rs/favicon.ico")]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
use std::{borrow::Cow, fmt, sync::Arc, time::Duration};
use actix_web::dev::ServiceRequest;
use redis::Client;
mod builder;
mod errors;
mod middleware;
mod status;
pub use self::{builder::Builder, errors::Error, middleware::RateLimiter, status::Status};
/// Default request limit.
pub const DEFAULT_REQUEST_LIMIT: usize = 5000;
/// Default period (in seconds).
pub const DEFAULT_PERIOD_SECS: u64 = 3600;
/// Default cookie name.
pub const DEFAULT_COOKIE_NAME: &str = "sid";
/// Default session key.
#[cfg(feature = "session")]
pub const DEFAULT_SESSION_KEY: &str = "rate-api-id";
/// Helper trait to impl Debug on GetKeyFn type
trait GetKeyFnT: Fn(&ServiceRequest) -> Option<String> {}
impl<T> GetKeyFnT for T where T: Fn(&ServiceRequest) -> Option<String> {}
/// Get key function type with auto traits
type GetKeyFn = dyn GetKeyFnT + Send + Sync;
/// Get key resolver function type
impl fmt::Debug for GetKeyFn {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "GetKeyFn")
}
}
/// Wrapped Get key function Trait
type GetArcBoxKeyFn = Arc<GetKeyFn>;
/// Rate limiter.
#[derive(Debug, Clone)]
pub struct Limiter {
client: Client,
limit: usize,
period: Duration,
get_key_fn: GetArcBoxKeyFn,
}
impl Limiter {
/// Construct rate limiter builder with defaults.
///
/// See [`redis-rs` docs](https://docs.rs/redis/0.21/redis/#connection-parameters) on connection
/// parameters for how to set the Redis URL.
#[must_use]
pub fn builder(redis_url: impl Into<String>) -> Builder {
Builder {
redis_url: redis_url.into(),
limit: DEFAULT_REQUEST_LIMIT,
period: Duration::from_secs(DEFAULT_PERIOD_SECS),
get_key_fn: None,
cookie_name: Cow::Borrowed(DEFAULT_COOKIE_NAME),
#[cfg(feature = "session")]
session_key: Cow::Borrowed(DEFAULT_SESSION_KEY),
}
}
/// Consumes one rate limit unit, returning the status.
pub async fn count(&self, key: impl Into<String>) -> Result<Status, Error> {
let (count, reset) = self.track(key).await?;
let status = Status::new(count, self.limit, reset);
if count > self.limit {
Err(Error::LimitExceeded(status))
} else {
Ok(status)
}
}
/// Tracks the given key in a period and returns the count and TTL for the key in seconds.
async fn track(&self, key: impl Into<String>) -> Result<(usize, usize), Error> {
let key = key.into();
let expires = self.period.as_secs();
let mut connection = self.client.get_multiplexed_tokio_connection().await?;
// The seed of this approach is outlined Atul R in a blog post about rate limiting using
// NodeJS and Redis. For more details, see https://blog.atulr.com/rate-limiter
let mut pipe = redis::pipe();
pipe.atomic()
.cmd("SET") // Set key and value
.arg(&key)
.arg(0)
.arg("EX") // Set the specified expire time, in seconds.
.arg(expires)
.arg("NX") // Only set the key if it does not already exist.
.ignore() // --- ignore returned value of SET command ---
.cmd("INCR") // Increment key
.arg(&key)
.cmd("TTL") // Return time-to-live of key
.arg(&key);
let (count, ttl) = pipe.query_async(&mut connection).await?;
let reset = Status::epoch_utc_plus(Duration::from_secs(ttl))?;
Ok((count, reset))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_limiter() {
let mut builder = Limiter::builder("redis://127.0.0.1:6379/1");
let limiter = builder.build();
assert!(limiter.is_ok());
let limiter = limiter.unwrap();
assert_eq!(limiter.limit, 5000);
assert_eq!(limiter.period, Duration::from_secs(3600));
}
}

View File

@ -1,115 +0,0 @@
use std::{future::Future, pin::Pin, rc::Rc};
use actix_utils::future::{ok, Ready};
use actix_web::{
body::EitherBody,
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
http::StatusCode,
web, Error, HttpResponse,
};
use crate::{Error as LimitationError, Limiter};
/// Rate limit middleware.
#[derive(Debug, Default)]
#[non_exhaustive]
pub struct RateLimiter;
impl<S, B> Transform<S, ServiceRequest> for RateLimiter
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = Error;
type Transform = RateLimiterMiddleware<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ok(RateLimiterMiddleware {
service: Rc::new(service),
})
}
}
/// Rate limit middleware service.
#[derive(Debug)]
pub struct RateLimiterMiddleware<S> {
service: Rc<S>,
}
impl<S, B> Service<ServiceRequest> for RateLimiterMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
// A misconfiguration of the Actix App will result in a **runtime** failure, so the expect
// method description is important context for the developer.
let limiter = req
.app_data::<web::Data<Limiter>>()
.expect("web::Data<Limiter> should be set in app data for RateLimiter middleware")
.clone();
let key = (limiter.get_key_fn)(&req);
let service = Rc::clone(&self.service);
let key = match key {
Some(key) => key,
None => {
return Box::pin(async move {
service
.call(req)
.await
.map(ServiceResponse::map_into_left_body)
});
}
};
Box::pin(async move {
let status = limiter.count(key.to_string()).await;
if let Err(err) = status {
match err {
LimitationError::LimitExceeded(_) => {
log::warn!("Rate limit exceed error for {}", key);
Ok(req.into_response(
HttpResponse::new(StatusCode::TOO_MANY_REQUESTS).map_into_right_body(),
))
}
LimitationError::Client(e) => {
log::error!("Client request failed, redis error: {}", e);
Ok(req.into_response(
HttpResponse::new(StatusCode::INTERNAL_SERVER_ERROR)
.map_into_right_body(),
))
}
_ => {
log::error!("Count failed: {}", err);
Ok(req.into_response(
HttpResponse::new(StatusCode::INTERNAL_SERVER_ERROR)
.map_into_right_body(),
))
}
}
} else {
service
.call(req)
.await
.map(ServiceResponse::map_into_left_body)
}
})
}
}

View File

@ -1,118 +0,0 @@
use std::{ops::Add, time::Duration};
use chrono::SubsecRound as _;
use crate::Error as LimitationError;
/// A report for a given key containing the limit status.
#[derive(Debug, Clone)]
pub struct Status {
pub(crate) limit: usize,
pub(crate) remaining: usize,
pub(crate) reset_epoch_utc: usize,
}
impl Status {
/// Constructs status limit status from parts.
#[must_use]
pub(crate) fn new(count: usize, limit: usize, reset_epoch_utc: usize) -> Self {
let remaining = limit.saturating_sub(count);
Status {
limit,
remaining,
reset_epoch_utc,
}
}
/// Returns the maximum number of requests allowed in the current period.
#[must_use]
pub fn limit(&self) -> usize {
self.limit
}
/// Returns how many requests are left in the current period.
#[must_use]
pub fn remaining(&self) -> usize {
self.remaining
}
/// Returns a UNIX timestamp in UTC approximately when the next period will begin.
#[must_use]
pub fn reset_epoch_utc(&self) -> usize {
self.reset_epoch_utc
}
pub(crate) fn epoch_utc_plus(duration: Duration) -> Result<usize, LimitationError> {
match chrono::Duration::from_std(duration) {
Ok(value) => Ok(chrono::Utc::now()
.add(value)
.round_subsecs(0)
.timestamp()
.try_into()
.unwrap_or(0)),
Err(_) => Err(LimitationError::Other(
"Source duration value is out of range for the target type".to_string(),
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_status() {
let status = Status {
limit: 100,
remaining: 0,
reset_epoch_utc: 1000,
};
assert_eq!(status.limit(), 100);
assert_eq!(status.remaining(), 0);
assert_eq!(status.reset_epoch_utc(), 1000);
}
#[test]
fn test_build_status() {
let count = 200;
let limit = 100;
let status = Status::new(count, limit, 2000);
assert_eq!(status.limit(), limit);
assert_eq!(status.remaining(), 0);
assert_eq!(status.reset_epoch_utc(), 2000);
}
#[test]
fn test_build_status_limit() {
let limit = 100;
let status = Status::new(0, limit, 2000);
assert_eq!(status.limit(), limit);
assert_eq!(status.remaining(), limit);
assert_eq!(status.reset_epoch_utc(), 2000);
}
#[test]
fn test_epoch_utc_plus_zero() {
let duration = Duration::from_secs(0);
let seconds = Status::epoch_utc_plus(duration).unwrap();
assert!(seconds as u64 >= duration.as_secs());
}
#[test]
fn test_epoch_utc_plus() {
let duration = Duration::from_secs(10);
let seconds = Status::epoch_utc_plus(duration).unwrap();
assert!(seconds as u64 >= duration.as_secs() + 10);
}
#[test]
#[should_panic = "Source duration value is out of range for the target type"]
fn test_epoch_utc_plus_overflow() {
let duration = Duration::from_secs(10000000000000000000);
Status::epoch_utc_plus(duration).unwrap();
}
}

View File

@ -1,92 +0,0 @@
use std::time::Duration;
use actix_limitation::{Error, Limiter, RateLimiter};
use actix_web::{dev::ServiceRequest, http::StatusCode, test, web, App, HttpRequest, HttpResponse};
use uuid::Uuid;
#[test]
#[should_panic = "Redis URL did not parse"]
async fn test_create_limiter_error() {
Limiter::builder("127.0.0.1").build().unwrap();
}
#[actix_web::test]
async fn test_limiter_count() -> Result<(), Error> {
let limiter = Limiter::builder("redis://127.0.0.1:6379/2")
.limit(20)
.build()
.unwrap();
let id = Uuid::new_v4();
for i in 0..20 {
let status = limiter.count(id.to_string()).await?;
println!("status: {status:?}");
assert_eq!(20 - status.remaining(), i + 1);
}
Ok(())
}
#[actix_web::test]
async fn test_limiter_count_error() -> Result<(), Error> {
let limiter = Limiter::builder("redis://127.0.0.1:6379/3")
.limit(25)
.build()
.unwrap();
let id = Uuid::new_v4();
for i in 0..25 {
let status = limiter.count(id.to_string()).await?;
assert_eq!(25 - status.remaining(), i + 1);
}
match limiter.count(id.to_string()).await.unwrap_err() {
Error::LimitExceeded(status) => assert_eq!(status.remaining(), 0),
_ => panic!("error should be LimitExceeded variant"),
};
let id = Uuid::new_v4();
for i in 0..25 {
let status = limiter.count(id.to_string()).await?;
assert_eq!(25 - status.remaining(), i + 1);
}
Ok(())
}
#[actix_web::test]
async fn test_limiter_key_by() -> Result<(), Error> {
let cooldown_period = Duration::from_secs(1);
let limiter = Limiter::builder("redis://127.0.0.1:6379/3")
.limit(2)
.period(cooldown_period)
.key_by(|_: &ServiceRequest| Some("fix_key".to_string()))
.build()
.unwrap();
let app = test::init_service(
App::new()
.wrap(RateLimiter::default())
.app_data(web::Data::new(limiter))
.route(
"/",
web::get().to(|_: HttpRequest| async { HttpResponse::Ok().body("ok") }),
),
)
.await;
for _ in 1..2 {
for index in 1..4 {
let req = test::TestRequest::default().to_request();
let resp = test::call_service(&app, req).await;
if index <= 2 {
assert!(resp.status().is_success());
} else {
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
}
}
std::thread::sleep(cooldown_period);
}
Ok(())
}

View File

@ -1,48 +1,28 @@
# Changes
## Unreleased
## Unreleased - 2021-xx-xx
## 0.11.0
- Updated `prost` dependency to `0.13`.
- Minimum supported Rust version (MSRV) is now 1.75.
## 0.10.0
- Updated `prost` dependency to `0.12`.
- Minimum supported Rust version (MSRV) is now 1.68.
## 0.9.0
- Added `application/x-protobuf` as an acceptable header.
- Updated `prost` dependency to `0.11`.
## 0.8.0
- Update `prost` dependency to `0.10`.
- Minimum supported Rust version (MSRV) is now 1.57 due to transitive `time` dependency.
## 0.7.0
## 0.7.0 - 2022-03-01
- Update `actix-web` dependency to `4`.
## 0.7.0-beta.5
## 0.7.0-beta.5 - 2022-02-03
- Update `prost` dependency to `0.9`.
- Update `actix-web` dependency to `4.0.0-rc.1`.
## 0.7.0-beta.4
## 0.7.0-beta.4 - 2021-12-29
- Minimum supported Rust version (MSRV) is now 1.54.
## 0.7.0-beta.3
## 0.7.0-beta.3 - 2021-12-12
- Update `actix-web` dependency to `4.0.0.beta-14`. [#209]
[#209]: https://github.com/actix/actix-extras/pull/209
## 0.7.0-beta.2
## 0.7.0-beta.2 - 2021-10-21
- Bump `prost` version to 0.8. [#197]
- Update `actix-web` dependency to v4.0.0-beta.10. [#203]
- Minimum supported Rust version (MSRV) is now 1.52.
@ -50,52 +30,52 @@
[#197]: https://github.com/actix/actix-extras/pull/197
[#203]: https://github.com/actix/actix-extras/pull/203
## 0.7.0-beta.1
## 0.7.0-beta.1 - 2021-06-27
- Bump `prost` version to 0.7. [#144]
- Update `actix-web` dependency to 4.0.0 beta.
- Minimum supported Rust version (MSRV) is now 1.46.0.
[#144]: https://github.com/actix/actix-extras/pull/144
## 0.6.0
## 0.6.0 - 2020-09-11
- Update `actix-web` dependency to 3.0.0.
- Minimum supported Rust version (MSRV) is now 1.42.0 to use `matches!` macro.
## 0.6.0-alpha.1
## 0.6.0-alpha.1 - 2020-07-06
- Update `actix-web` to 3.0.0-alpha.3
- Minimum supported Rust version(MSRV) is now 1.40.0.
- Minimize `futures` dependency
## 0.5.1 - 2019-02-17
## 0.5.1 - 2019-02-17
- Move repository to actix-extras
## 0.5.0 - 2019-01-24
## 0.5.0 - 2019-01-24
- Migrate to actix-web 2.0.0 and std::future
- Update prost to 0.6
- Update bytes to 0.5
## 0.4.1 - 2019-10-03
## 0.4.1 - 2019-10-03
- Upgrade prost and prost-derive to 0.5.0
## 0.4.0 - 2019-05-18
## 0.4.0 - 2019-05-18
- Upgrade to actix-web 1.0.0-rc
- Removed `protobuf` method for `HttpRequest` (use `ProtoBuf` extractor instead)
## 0.3.0 - 2019-03-07
## 0.3.0 - 2019-03-07
- Upgrade to actix-web 0.7.18
## 0.2.0 - 2018-04-10
## 0.2.0 - 2018-04-10
- Provide protobuf extractor
## 0.1.0 - 2018-03-21
## 0.1.0 - 2018-03-21
- First release

View File

@ -1,31 +1,28 @@
[package]
name = "actix-protobuf"
version = "0.11.0"
version = "0.7.0"
edition = "2018"
authors = [
"kingxsp <jin.hb.zh@outlook.com>",
"Yuki Okushi <huyuumi.dev@gmail.com>",
"Yuki Okushi <huyuumi.dev@gmail.com>"
]
description = "Protobuf payload extractor for Actix Web"
keywords = ["actix", "web", "protobuf", "protocol", "rpc"]
repository.workspace = true
homepage.workspace = true
license.workspace = true
edition.workspace = true
rust-version.workspace = true
description = "Protobuf support for Actix web"
keywords = ["actix", "protobuf", "protocol", "rpc"]
homepage = "https://actix.rs"
repository = "https://github.com/actix/actix-extras.git"
license = "MIT OR Apache-2.0"
exclude = [".cargo/config", "/examples/**"]
[package.metadata.docs.rs]
rustdoc-args = ["--cfg", "docsrs"]
all-features = true
[lib]
name = "actix_protobuf"
path = "src/lib.rs"
[dependencies]
actix-web = { version = "4", default-features = false }
derive_more = { version = "2", features = ["display"] }
futures-util = { version = "0.3.17", default-features = false, features = ["std"] }
prost = { version = "0.13", default-features = false }
actix-web = { version = "4", default_features = false }
derive_more = "0.99.5"
futures-util = { version = "0.3.7", default-features = false }
prost = { version = "0.9", default_features = false }
[dev-dependencies]
actix-web = { version = "4", default-features = false, features = ["macros"] }
prost = { version = "0.13", default-features = false, features = ["prost-derive"] }
[lints]
workspace = true
actix-web = { version = "4", default_features = false, features = ["macros"] }
prost = { version = "0.9", default_features = false, features = ["prost-derive"] }

View File

@ -1,21 +1,17 @@
# actix-protobuf
> Protobuf payload extractor for Actix Web.
<!-- prettier-ignore-start -->
> Protobuf support for Actix Web.
[![crates.io](https://img.shields.io/crates/v/actix-protobuf?label=latest)](https://crates.io/crates/actix-protobuf)
[![Documentation](https://docs.rs/actix-protobuf/badge.svg?version=0.11.0)](https://docs.rs/actix-protobuf/0.11.0)
[![Documentation](https://docs.rs/actix-protobuf/badge.svg?version=0.7.0)](https://docs.rs/actix-protobuf/0.7.0)
![Apache 2.0 or MIT licensed](https://img.shields.io/crates/l/actix-protobuf)
[![Dependency Status](https://deps.rs/crate/actix-protobuf/0.11.0/status.svg)](https://deps.rs/crate/actix-protobuf/0.11.0)
<!-- prettier-ignore-end -->
[![Dependency Status](https://deps.rs/crate/actix-protobuf/0.7.0/status.svg)](https://deps.rs/crate/actix-protobuf/0.7.0)
## Documentation & Resources
- [API Documentation](https://docs.rs/actix-protobuf)
- [Example Project](https://github.com/actix/examples/tree/master/protobuf)
- Minimum Supported Rust Version (MSRV): 1.57
- Minimum Supported Rust Version (MSRV): 1.54
## Example
@ -27,7 +23,6 @@ use actix_web::*;
pub struct MyObj {
#[prost(int32, tag = "1")]
pub number: i32,
#[prost(string, tag = "2")]
pub name: String,
}
@ -38,7 +33,7 @@ async fn index(msg: ProtoBuf<MyObj>) -> Result<HttpResponse> {
}
```
See [here](https://github.com/actix/examples/tree/master/protobuf) for the complete example.
See [here](https://github.com/actix/actix-extras/tree/master/actix-protobuf/examples/prost-example) for the complete example.
## License

View File

@ -0,0 +1,15 @@
[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

@ -0,0 +1,68 @@
#!/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

@ -0,0 +1,33 @@
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

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

View File

@ -0,0 +1,75 @@
# -*- 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,9 +1,6 @@
//! Protobuf payload extractor for Actix Web.
#![forbid(unsafe_code)]
#![doc(html_logo_url = "https://actix.rs/img/logo.png")]
#![doc(html_favicon_url = "https://actix.rs/favicon.ico")]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![deny(rust_2018_idioms, nonstandard_style)]
#![warn(future_incompatible)]
use std::{
fmt,
@ -22,7 +19,7 @@ use actix_web::{
Error, FromRequest, HttpMessage, HttpRequest, HttpResponse, HttpResponseBuilder, Responder,
ResponseError,
};
use derive_more::derive::Display;
use derive_more::Display;
use futures_util::{
future::{FutureExt as _, LocalBoxFuture},
stream::StreamExt as _,
@ -32,28 +29,26 @@ use prost::{DecodeError as ProtoBufDecodeError, EncodeError as ProtoBufEncodeErr
#[derive(Debug, Display)]
pub enum ProtoBufPayloadError {
/// Payload size is bigger than 256k
#[display("Payload size is bigger than 256k")]
#[display(fmt = "Payload size is bigger than 256k")]
Overflow,
/// Content type error
#[display("Content type error")]
#[display(fmt = "Content type error")]
ContentType,
/// Serialize error
#[display("ProtoBuf serialize error: {_0}")]
#[display(fmt = "ProtoBuf serialize error: {}", _0)]
Serialize(ProtoBufEncodeError),
/// Deserialize error
#[display("ProtoBuf deserialize error: {_0}")]
#[display(fmt = "ProtoBuf deserialize error: {}", _0)]
Deserialize(ProtoBufDecodeError),
/// Payload error
#[display("Error that occur during reading payload: {_0}")]
#[display(fmt = "Error that occur during reading payload: {}", _0)]
Payload(PayloadError),
}
// TODO: impl error for ProtoBufPayloadError
impl ResponseError for ProtoBufPayloadError {
fn error_response(&self) -> HttpResponse {
match *self {
@ -175,9 +170,7 @@ pub struct ProtoBufMessage<T: Message + Default> {
impl<T: Message + Default> ProtoBufMessage<T> {
/// Create `ProtoBufMessage` for request.
pub fn new(req: &HttpRequest, payload: &mut Payload) -> Self {
if req.content_type() != "application/protobuf"
&& req.content_type() != "application/x-protobuf"
{
if req.content_type() != "application/protobuf" {
return ProtoBufMessage {
limit: 262_144,
length: None,
@ -269,16 +262,15 @@ impl ProtoBufResponseBuilder for HttpResponseBuilder {
value
.encode(&mut body)
.map_err(ProtoBufPayloadError::Serialize)?;
Ok(self.body(body))
}
}
#[cfg(test)]
mod tests {
use actix_web::{http::header, test::TestRequest};
use super::*;
use actix_web::http::header;
use actix_web::test::TestRequest;
impl PartialEq for ProtoBufPayloadError {
fn eq(&self, other: &ProtoBufPayloadError) -> bool {
@ -294,7 +286,7 @@ mod tests {
}
}
#[derive(Clone, PartialEq, Eq, Message)]
#[derive(Clone, PartialEq, Message)]
pub struct MyObject {
#[prost(int32, tag = "1")]
pub number: i32,

135
actix-redis/CHANGES.md Normal file
View File

@ -0,0 +1,135 @@
# Changes
## Unreleased - 2021-xx-xx
## 0.10.0 - 2022-03-01
- Update `actix-web` dependency to `4`.
## 0.10.0-beta.6 - 2022-02-07
- Update `actix-web` dependency to `4.0.0-rc.1`.
## 0.10.0-beta.5 - 2021-12-29
- Update `actix-web` dependency to `4.0.0.beta-18`. [#218]
- Minimum supported Rust version (MSRV) is now 1.54.
[#218]: https://github.com/actix/actix-extras/pull/218
## 0.10.0-beta.4 - 2021-12-12
- A session will be created in Redis if and only if there is some data inside the session state. This reduces the performance impact of `RedisSession` on routes that do not leverage sessions. [#207]
- Update `actix-web` dependency to `4.0.0.beta-14`. [#209]
[#207]: https://github.com/actix/actix-extras/pull/207
[#209]: https://github.com/actix/actix-extras/pull/209
## 0.10.0-beta.3 - 2021-10-21
- Update `actix-web` dependency to v4.0.0-beta.10. [#203]
- Minimum supported Rust version (MSRV) is now 1.52.
[#203]: https://github.com/actix/actix-extras/pull/203
## 0.10.0-beta.2 - 2021-06-27
- No notable changes.
## 0.10.0-beta.1 - 2021-04-02
- Update `actix-web` dependency to 4.0.0 beta.
- Minimum supported Rust version (MSRV) is now 1.46.0.
## 0.9.2 - 2021-03-21
- Implement `std::error::Error` for `Error` [#135]
- Allow the removal of `Max-Age` for session-only cookies. [#161]
[#135]: https://github.com/actix/actix-extras/pull/135
[#161]: https://github.com/actix/actix-extras/pull/161
## 0.9.1 - 2020-09-12
- Enforce minimum redis-async version of 0.6.3 to workaround breaking patch change.
## 0.9.0 - 2020-09-11
- Update `actix-web` dependency to 3.0.0.
- Minimize `futures` dependency.
## 0.9.0-alpha.2 - 2020-05-17
- Add `cookie_http_only` functionality to RedisSession builder, setting this
to false allows JavaScript to access cookies. Defaults to true.
- Change type of parameter of ttl method to u32.
- Update `actix` to 0.10.0-alpha.3
- Update `tokio-util` to 0.3
- Minimum supported Rust version(MSRV) is now 1.40.0.
## 0.9.0-alpha.1 - 2020-03-28
- Update `actix` to 0.10.0-alpha.2
- Update `actix-session` to 0.4.0-alpha.1
- Update `actix-web` to 3.0.0-alpha.1
- Update `time` to 0.2.9
## 0.8.1 - 2020-02-18
- Move `env_logger` dependency to dev-dependencies and update to 0.7
- Update `actix_web` to 2.0.0 from 2.0.0-rc
- Move repository to actix-extras
## 0.8.0 - 2019-12-20
- Release
## 0.8.0-alpha.1 - 2019-12-16
- Migrate to actix 0.9
## 0.7.0 - 2019-09-25
- added cache_keygen functionality to RedisSession builder, enabling support for
customizable cache key creation
## 0.6.1 - 2019-07-19
- remove ClonableService usage
- added comprehensive tests for session workflow
## 0.6.0 - 2019-07-08
- actix-web 1.0.0 compatibility
- Upgraded logic that evaluates session state, including new SessionStatus field,
and introduced ``session.renew()`` and ``session.purge()`` functionality.
Use ``renew()`` to cycle the session key at successful login. ``renew()`` keeps a
session's state while replacing the old cookie and session key with new ones.
Use ``purge()`` at logout to invalidate the session cookie and remove the
session's redis cache entry.
## 0.5.1 - 2018-08-02
- Use cookie 0.11
## 0.5.0 - 2018-07-21
- Session cookie configuration
- Actix/Actix-web 0.7 compatibility
## 0.4.0 - 2018-05-08
- Actix web 0.6 compatibility
## 0.3.0 - 2018-04-10
- Actix web 0.5 compatibility
## 0.2.0 - 2018-02-28
- Use resolver actor from actix
- Use actix web 0.5
## 0.1.0 - 2018-01-23
- First release

66
actix-redis/Cargo.toml Normal file
View File

@ -0,0 +1,66 @@
[package]
name = "actix-redis"
version = "0.10.0"
authors = ["Nikolay Kim <fafhrd91@gmail.com>"]
description = "Redis integration for Actix and session store for Actix Web"
license = "MIT OR Apache-2.0"
keywords = ["actix", "redis", "async", "session"]
homepage = "https://actix.rs"
repository = "https://github.com/actix/actix-extras.git"
categories = ["network-programming", "asynchronous"]
exclude = [".cargo/config"]
edition = "2018"
[lib]
name = "actix_redis"
path = "src/lib.rs"
[features]
default = ["web"]
# actix-web integration
web = [
"actix-web/cookies",
"actix-web/secure-cookies",
"actix-session/cookie-session",
"rand",
"serde",
"serde_json"
]
[dependencies]
actix = { version = "0.12", default-features = false }
actix-rt = { version = "2.1", default-features = false }
actix-service = "2"
actix-tls = { version = "3", default-features = false, features = ["connect"] }
log = "0.4.6"
backoff = "0.2.1"
derive_more = "0.99.5"
futures-core = { version = "0.3.7", default-features = false }
redis2 = { package = "redis", version = "0.19", features = ["tokio-comp", "tokio-native-tls-comp"] }
redis-async = { version = "0.8", default-features = false, features = ["tokio10"] }
time = "0.3"
tokio = { version = "1.13.1", features = ["sync"] }
tokio-util = "0.6"
# web
actix-web = { version = "4", default_features = false, optional = true }
actix-session = { version = "0.5", optional = true }
rand = { version = "0.8", optional = true }
serde = { version = "1.0.101", optional = true }
serde_json = { version = "1.0.40", optional = true }
[dev-dependencies]
actix-test = "0.1.0-beta.12"
actix-web = { version = "4", default_features = false, features = ["macros"] }
env_logger = "0.9"
serde = { version = "1.0.101", features = ["derive"] }
[[example]]
name = "basic"
required-features = ["web"]
[[example]]
name = "authentication"
required-features = ["web"]

47
actix-redis/README.md Normal file
View File

@ -0,0 +1,47 @@
# actix-redis
> Redis integration for Actix and session store for Actix Web.
[![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.10.0)](https://docs.rs/actix-redis/0.10.0)
![Apache 2.0 or MIT licensed](https://img.shields.io/crates/l/actix-redis)
[![Dependency Status](https://deps.rs/crate/actix-redis/0.10.0/status.svg)](https://deps.rs/crate/actix-redis/0.10.0)
## Documentation & Resources
- [API Documentation](https://docs.rs/actix-redis)
- [Example Project](https://github.com/actix/examples/tree/master/auth/redis-session)
- Minimum Supported Rust Version (MSRV): 1.54
## Redis Session Backend
Use redis as session storage.
You need to pass an address of the redis server and random value to the
constructor of `RedisSession`. This is private key for cookie session,
When this value is changed, all session data is lost.
Note that whatever you write into your session is visible by the user (but not modifiable).
Constructor panics if key length is less than 32 bytes.
```rust
use actix_web::{App, HttpServer, middleware::Logger};
use actix_web::web::{resource, get}
use actix_redis::RedisSession;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(move || App::new()
// cookie session middleware
.wrap(RedisSession::new("127.0.0.1:6379", &[0; 32]))
// enable logger
.wrap(Logger::default())
// register simple route, handle all methods
.service(resource("/").route(get().to(index)))
)
.bind("127.0.0.1:8080")?
.run()
.await
}
```

View File

@ -1,12 +1,9 @@
use actix_session::{storage::RedisSessionStore, Session, SessionMiddleware};
use actix_redis::RedisSession;
use actix_session::Session;
use actix_web::{
cookie::{Key, SameSite},
error::InternalError,
middleware, web, App, Error, HttpResponse, HttpServer, Responder,
cookie, error::InternalError, middleware, web, App, Error, HttpResponse, HttpServer, Responder,
};
use serde::{Deserialize, Serialize};
use tracing::level_filters::LevelFilter;
use tracing_subscriber::EnvFilter;
#[derive(Deserialize)]
struct Credentials {
@ -23,7 +20,7 @@ struct User {
impl User {
fn authenticate(credentials: Credentials) -> Result<Self, HttpResponse> {
// to do: figure out why I keep getting hacked /s
// TODO: figure out why I keep getting hacked
if &credentials.password != "hunter2" {
return Err(HttpResponse::Unauthorized().json("Unauthorized"));
}
@ -73,39 +70,25 @@ async fn secret(session: Session) -> Result<impl Responder, Error> {
#[actix_web::main]
async fn main() -> std::io::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy(),
)
.init();
std::env::set_var("RUST_LOG", "actix_web=info,actix_redis=info");
env_logger::init();
// The signing key would usually be read from a configuration file/environment variables.
let signing_key = Key::generate();
tracing::info!("setting up Redis session storage");
let storage = RedisSessionStore::new("127.0.0.1:6379").await.unwrap();
tracing::info!("starting HTTP server at http://localhost:8080");
HttpServer::new(move || {
HttpServer::new(|| {
App::new()
// enable logger
.wrap(middleware::Logger::default())
// cookie session middleware
.wrap(
SessionMiddleware::builder(storage.clone(), signing_key.clone())
RedisSession::new("127.0.0.1:6379", &[0; 32])
// allow the cookie to be accessed from javascript
.cookie_http_only(false)
// allow the cookie only from the current domain
.cookie_same_site(SameSite::Strict)
.build(),
.cookie_same_site(cookie::SameSite::Strict),
)
.route("/login", web::post().to(login))
.route("/secret", web::get().to(secret))
})
.bind(("127.0.0.1", 8080))?
.bind("0.0.0.0:8080")?
.run()
.await
}

View File

@ -0,0 +1,37 @@
use actix_redis::RedisSession;
use actix_session::Session;
use actix_web::{middleware, web, App, Error, HttpRequest, HttpServer, Responder};
/// simple handler
async fn index(req: HttpRequest, session: Session) -> Result<impl Responder, Error> {
println!("{:?}", req);
// session
if let Some(count) = session.get::<i32>("counter")? {
println!("SESSION value: {}", count);
session.insert("counter", count + 1)?;
} else {
session.insert("counter", 1)?;
}
Ok("Welcome!")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
std::env::set_var("RUST_LOG", "actix_web=info,actix_redis=info");
env_logger::init();
HttpServer::new(|| {
App::new()
// enable logger
.wrap(middleware::Logger::default())
// cookie session middleware
.wrap(RedisSession::new("127.0.0.1:6379", &[0; 32]))
// register simple route, handle all methods
.service(web::resource("/").to(index))
})
.bind("0.0.0.0:8080")?
.run()
.await
}

36
actix-redis/src/lib.rs Normal file
View File

@ -0,0 +1,36 @@
//! Redis integration for Actix and session store for Actix Web.
#![deny(rust_2018_idioms, nonstandard_style)]
#![warn(future_incompatible)]
mod redis;
pub use redis::{Command, RedisActor};
use derive_more::{Display, Error, From};
#[cfg(feature = "web")]
mod session;
#[cfg(feature = "web")]
pub use actix_web::cookie::SameSite;
#[cfg(feature = "web")]
pub use session::RedisSession;
/// General purpose actix redis error
#[derive(Debug, Display, Error, From)]
pub enum Error {
#[display(fmt = "Redis error {}", _0)]
Redis(redis_async::error::Error),
/// Receiving message during reconnecting
#[display(fmt = "Redis: Not connected")]
NotConnected,
/// Cancel all waters when connection get dropped
#[display(fmt = "Redis: Disconnected")]
Disconnected,
}
#[cfg(feature = "web")]
impl actix_web::ResponseError for Error {}
// re-export
pub use redis_async::error::Error as RespError;
pub use redis_async::resp::RespValue;

141
actix-redis/src/redis.rs Normal file
View File

@ -0,0 +1,141 @@
use std::collections::VecDeque;
use std::io;
use actix::prelude::*;
use actix_rt::net::TcpStream;
use actix_service::boxed::{self, BoxService};
use actix_tls::connect::{ConnectError, ConnectInfo, Connection, ConnectorService};
use backoff::backoff::Backoff;
use backoff::ExponentialBackoff;
use log::{error, info, warn};
use redis_async::error::Error as RespError;
use redis_async::resp::{RespCodec, RespValue};
use tokio::io::{split, WriteHalf};
use tokio::sync::oneshot;
use tokio_util::codec::FramedRead;
use crate::Error;
/// Command for send data to Redis
#[derive(Debug)]
pub struct Command(pub RespValue);
impl Message for Command {
type Result = Result<RespValue, Error>;
}
/// Redis communication actor
pub struct RedisActor {
addr: String,
connector: BoxService<ConnectInfo<String>, Connection<String, TcpStream>, ConnectError>,
backoff: ExponentialBackoff,
cell: Option<actix::io::FramedWrite<RespValue, WriteHalf<TcpStream>, RespCodec>>,
queue: VecDeque<oneshot::Sender<Result<RespValue, Error>>>,
}
impl RedisActor {
/// Start new `Supervisor` with `RedisActor`.
pub fn start<S: Into<String>>(addr: S) -> Addr<RedisActor> {
let addr = addr.into();
let backoff = ExponentialBackoff {
max_elapsed_time: None,
..Default::default()
};
Supervisor::start(|_| RedisActor {
addr,
connector: boxed::service(ConnectorService::default()),
cell: None,
backoff,
queue: VecDeque::new(),
})
}
}
impl Actor for RedisActor {
type Context = Context<Self>;
fn started(&mut self, ctx: &mut Context<Self>) {
let req = ConnectInfo::new(self.addr.to_owned());
self.connector
.call(req)
.into_actor(self)
.map(|res, act, ctx| match res {
Ok(conn) => {
let stream = conn.into_parts().0;
info!("Connected to redis server: {}", act.addr);
let (r, w) = split(stream);
// configure write side of the connection
let framed = actix::io::FramedWrite::new(w, RespCodec, ctx);
act.cell = Some(framed);
// read side of the connection
ctx.add_stream(FramedRead::new(r, RespCodec));
act.backoff.reset();
}
Err(err) => {
error!("Can not connect to redis server: {}", err);
// re-connect with backoff time.
// we stop current context, supervisor will restart it.
if let Some(timeout) = act.backoff.next_backoff() {
ctx.run_later(timeout, |_, ctx| ctx.stop());
}
}
})
.wait(ctx);
}
}
impl Supervised for RedisActor {
fn restarting(&mut self, _: &mut Self::Context) {
self.cell.take();
for tx in self.queue.drain(..) {
let _ = tx.send(Err(Error::Disconnected));
}
}
}
impl actix::io::WriteHandler<io::Error> for RedisActor {
fn error(&mut self, err: io::Error, _: &mut Self::Context) -> Running {
warn!("Redis connection dropped: {} error: {}", self.addr, err);
Running::Stop
}
}
impl StreamHandler<Result<RespValue, RespError>> for RedisActor {
fn handle(&mut self, msg: Result<RespValue, RespError>, ctx: &mut Self::Context) {
match msg {
Err(e) => {
if let Some(tx) = self.queue.pop_front() {
let _ = tx.send(Err(e.into()));
}
ctx.stop();
}
Ok(val) => {
if let Some(tx) = self.queue.pop_front() {
let _ = tx.send(Ok(val));
}
}
}
}
}
impl Handler<Command> for RedisActor {
type Result = ResponseFuture<Result<RespValue, Error>>;
fn handle(&mut self, msg: Command, _: &mut Self::Context) -> Self::Result {
let (tx, rx) = oneshot::channel();
if let Some(ref mut cell) = self.cell {
self.queue.push_back(tx);
cell.write(msg.0);
} else {
let _ = tx.send(Err(Error::NotConnected));
}
Box::pin(async move { rx.await.map_err(|_| Error::Disconnected)? })
}
}

684
actix-redis/src/session.rs Normal file
View File

@ -0,0 +1,684 @@
use std::{collections::HashMap, iter, rc::Rc};
use actix::prelude::*;
use actix_service::{Service, Transform};
use actix_session::{Session, SessionStatus};
use actix_web::{
cookie::{Cookie, CookieJar, Key, SameSite},
dev::{ServiceRequest, ServiceResponse},
error,
http::header::{self, HeaderValue},
Error,
};
use futures_core::future::LocalBoxFuture;
use rand::{distributions::Alphanumeric, rngs::OsRng, Rng};
use redis_async::{resp::RespValue, resp_array};
use time::{self, Duration, OffsetDateTime};
use crate::redis::{Command, RedisActor};
/// Use redis as session storage.
///
/// You need to pass an address of the redis server and random value to the
/// constructor of `RedisSession`. This is private key for cookie
/// session, When this value is changed, all session data is lost.
///
/// Constructor panics if key length is less than 32 bytes.
pub struct RedisSession(Rc<Inner>);
impl RedisSession {
/// Create new redis session backend
///
/// * `addr` - address of the redis server
pub fn new<S: Into<String>>(addr: S, key: &[u8]) -> RedisSession {
RedisSession(Rc::new(Inner {
key: Key::derive_from(key),
cache_keygen: Box::new(|key: &str| format!("session:{}", &key)),
ttl: "7200".to_owned(),
addr: RedisActor::start(addr),
name: "actix-session".to_owned(),
path: "/".to_owned(),
domain: None,
secure: false,
max_age: Some(Duration::days(7)),
same_site: None,
http_only: true,
}))
}
/// Set time to live in seconds for session value.
pub fn ttl(mut self, ttl: u32) -> Self {
Rc::get_mut(&mut self.0).unwrap().ttl = format!("{}", ttl);
self
}
/// Set custom cookie name for session ID.
pub fn cookie_name(mut self, name: &str) -> Self {
Rc::get_mut(&mut self.0).unwrap().name = name.to_owned();
self
}
/// Set custom cookie path.
pub fn cookie_path(mut self, path: &str) -> Self {
Rc::get_mut(&mut self.0).unwrap().path = path.to_owned();
self
}
/// Set custom cookie domain.
pub fn cookie_domain(mut self, domain: &str) -> Self {
Rc::get_mut(&mut self.0).unwrap().domain = Some(domain.to_owned());
self
}
/// Set custom cookie secure.
///
/// If the `secure` field is set, a cookie will only be transmitted when the
/// connection is secure - i.e. `https`.
///
/// Default is false.
pub fn cookie_secure(mut self, secure: bool) -> Self {
Rc::get_mut(&mut self.0).unwrap().secure = secure;
self
}
/// Set custom cookie max-age.
///
/// Use `None` for session-only cookies.
pub fn cookie_max_age(mut self, max_age: impl Into<Option<Duration>>) -> Self {
Rc::get_mut(&mut self.0).unwrap().max_age = max_age.into();
self
}
/// Set custom cookie `SameSite` attribute.
///
/// By default, the attribute is omitted.
pub fn cookie_same_site(mut self, same_site: SameSite) -> Self {
Rc::get_mut(&mut self.0).unwrap().same_site = Some(same_site);
self
}
/// Set custom cookie `HttpOnly` policy.
///
/// Default is true.
pub fn cookie_http_only(mut self, http_only: bool) -> Self {
Rc::get_mut(&mut self.0).unwrap().http_only = http_only;
self
}
/// Set a custom cache key generation strategy, expecting session key as input.
pub fn cache_keygen(mut self, keygen: Box<dyn Fn(&str) -> String>) -> Self {
Rc::get_mut(&mut self.0).unwrap().cache_keygen = keygen;
self
}
}
impl<S, B> Transform<S, ServiceRequest> for RedisSession
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = S::Error;
type Transform = RedisSessionMiddleware<S>;
type InitError = ();
type Future = LocalBoxFuture<'static, Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
let inner = self.0.clone();
Box::pin(async {
Ok(RedisSessionMiddleware {
service: Rc::new(service),
inner,
})
})
}
}
/// Cookie session middleware
pub struct RedisSessionMiddleware<S: 'static> {
service: Rc<S>,
inner: Rc<Inner>,
}
impl<S, B> Service<ServiceRequest> for RedisSessionMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
actix_service::forward_ready!(service);
fn call(&self, mut req: ServiceRequest) -> Self::Future {
let srv = Rc::clone(&self.service);
let inner = Rc::clone(&self.inner);
Box::pin(async move {
let state = inner.load(&req).await?;
let value = if let Some((state, value)) = state {
Session::set_session(&mut req, state);
Some(value)
} else {
None
};
let mut res = srv.call(req).await?;
match Session::get_changes(&mut res) {
(SessionStatus::Unchanged, _) => {
// If the session already exists, we don't need to update the state stored in Redis
// If the session is new, creating an empty session in Redis is unnecessary overhead
Ok(res)
}
(SessionStatus::Changed, state) => inner.update(res, state, value).await,
(SessionStatus::Purged, _) => {
if let Some(val) = value {
inner.clear_cache(val).await?;
match inner.remove_cookie(&mut res) {
Ok(_) => Ok(res),
Err(_err) => Err(error::ErrorInternalServerError(_err)),
}
} else {
Err(error::ErrorInternalServerError("unexpected"))
}
}
(SessionStatus::Renewed, state) => {
if let Some(val) = value {
inner.clear_cache(val).await?;
inner.update(res, state, None).await
} else {
inner.update(res, state, None).await
}
}
}
})
}
}
struct Inner {
key: Key,
cache_keygen: Box<dyn Fn(&str) -> String>,
ttl: String,
addr: Addr<RedisActor>,
name: String,
path: String,
domain: Option<String>,
secure: bool,
max_age: Option<Duration>,
same_site: Option<SameSite>,
http_only: bool,
}
impl Inner {
async fn load(
&self,
req: &ServiceRequest,
) -> Result<Option<(HashMap<String, String>, String)>, Error> {
// wrapped in block to avoid holding `Ref` (from `req.cookies`) across await point
let (value, cache_key) = {
let cookies = if let Ok(cookies) = req.cookies() {
cookies
} else {
return Ok(None);
};
if let Some(cookie) = cookies.iter().find(|&cookie| cookie.name() == self.name) {
let mut jar = CookieJar::new();
jar.add_original(cookie.clone());
if let Some(cookie) = jar.signed(&self.key).get(&self.name) {
let value = cookie.value().to_owned();
let cache_key = (self.cache_keygen)(cookie.value());
(value, cache_key)
} else {
return Ok(None);
}
} else {
return Ok(None);
}
};
let val = self
.addr
.send(Command(resp_array!["GET", cache_key]))
.await
.map_err(error::ErrorInternalServerError)?
.map_err(error::ErrorInternalServerError)?;
match val {
RespValue::Error(err) => {
return Err(error::ErrorInternalServerError(err));
}
RespValue::SimpleString(s) => {
if let Ok(val) = serde_json::from_str(&s) {
return Ok(Some((val, value)));
}
}
RespValue::BulkString(s) => {
if let Ok(val) = serde_json::from_slice(&s) {
return Ok(Some((val, value)));
}
}
_ => {}
}
Ok(None)
}
async fn update<B>(
&self,
mut res: ServiceResponse<B>,
state: impl Iterator<Item = (String, String)>,
value: Option<String>,
) -> Result<ServiceResponse<B>, Error> {
let (value, jar) = if let Some(value) = value {
(value, None)
} else {
let value = iter::repeat(())
.map(|()| OsRng.sample(Alphanumeric))
.take(32)
.collect::<Vec<_>>();
let value = String::from_utf8(value).unwrap_or_default();
// prepare session id cookie
let mut cookie = Cookie::new(self.name.clone(), value.clone());
cookie.set_path(self.path.clone());
cookie.set_secure(self.secure);
cookie.set_http_only(self.http_only);
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(same_site) = self.same_site {
cookie.set_same_site(same_site);
}
// set cookie
let mut jar = CookieJar::new();
jar.signed_mut(&self.key).add(cookie);
(value, Some(jar))
};
let cache_key = (self.cache_keygen)(&value);
let state: HashMap<_, _> = state.collect();
let body = match serde_json::to_string(&state) {
Err(err) => return Err(err.into()),
Ok(body) => body,
};
let cmd = Command(resp_array!["SET", cache_key, body, "EX", &self.ttl]);
self.addr
.send(cmd)
.await
.map_err(error::ErrorInternalServerError)?
.map_err(error::ErrorInternalServerError)?;
if let Some(jar) = jar {
for cookie in jar.delta() {
let val = HeaderValue::from_str(&cookie.to_string())?;
res.headers_mut().append(header::SET_COOKIE, val);
}
}
Ok(res)
}
/// Removes cache entry.
async fn clear_cache(&self, key: String) -> Result<(), Error> {
let cache_key = (self.cache_keygen)(&key);
let res = self
.addr
.send(Command(resp_array!["DEL", cache_key]))
.await
.map_err(error::ErrorInternalServerError)?;
match res {
// redis responds with number of deleted records
Ok(RespValue::Integer(x)) if x > 0 => Ok(()),
_ => Err(error::ErrorInternalServerError(
"failed to remove session from cache",
)),
}
}
/// Invalidates session cookie.
fn remove_cookie<B>(&self, res: &mut ServiceResponse<B>) -> Result<(), Error> {
let mut cookie = Cookie::named(self.name.clone());
cookie.set_value("");
cookie.set_max_age(Duration::ZERO);
cookie.set_expires(OffsetDateTime::now_utc() - Duration::days(365));
let val =
HeaderValue::from_str(&cookie.to_string()).map_err(error::ErrorInternalServerError)?;
res.headers_mut().append(header::SET_COOKIE, val);
Ok(())
}
}
#[cfg(test)]
mod test {
use super::*;
use actix_session::Session;
use actix_web::{
middleware, web,
web::{get, post, resource},
App, HttpResponse, Result,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct IndexResponse {
user_id: Option<String>,
counter: i32,
}
async fn index(session: Session) -> Result<HttpResponse> {
let user_id: Option<String> = session.get::<String>("user_id").unwrap();
let counter: i32 = session
.get::<i32>("counter")
.unwrap_or(Some(0))
.unwrap_or(0);
Ok(HttpResponse::Ok().json(&IndexResponse { user_id, counter }))
}
async fn do_something(session: Session) -> Result<HttpResponse> {
let user_id: Option<String> = session.get::<String>("user_id").unwrap();
let counter: i32 = session
.get::<i32>("counter")
.unwrap_or(Some(0))
.map_or(1, |inner| inner + 1);
session.insert("counter", &counter)?;
Ok(HttpResponse::Ok().json(&IndexResponse { user_id, counter }))
}
#[derive(Deserialize)]
struct Identity {
user_id: String,
}
async fn login(user_id: web::Json<Identity>, session: Session) -> Result<HttpResponse> {
let id = user_id.into_inner().user_id;
session.insert("user_id", &id)?;
session.renew();
let counter: i32 = session
.get::<i32>("counter")
.unwrap_or(Some(0))
.unwrap_or(0);
Ok(HttpResponse::Ok().json(&IndexResponse {
user_id: Some(id),
counter,
}))
}
async fn logout(session: Session) -> Result<HttpResponse> {
let id: Option<String> = session.get("user_id")?;
let body = if let Some(x) = id {
session.purge();
format!("Logged out: {}", x)
} else {
"Could not log out anonymous user".to_owned()
};
Ok(HttpResponse::Ok().body(body))
}
#[actix_web::test]
async fn test_session_workflow() {
// Step 1: GET index
// - set-cookie actix-session should NOT be in response (session data is empty)
// - response should be: {"counter": 0, "user_id": None}
// Step 2: POST to do_something
// - adds new session state in redis: {"counter": 1}
// - set-cookie actix-session should be in response (session cookie #1)
// - response should be: {"counter": 1, "user_id": None}
// Step 3: GET index, including session cookie #1 in request
// - set-cookie will *not* be in response
// - response should be: {"counter": 1, "user_id": None}
// Step 4: POST again to do_something, including session cookie #1 in request
// - updates session state in redis: {"counter": 2}
// - response should be: {"counter": 2, "user_id": None}
// Step 5: POST to login, including session cookie #1 in request
// - set-cookie actix-session will be in response (session cookie #2)
// - updates session state in redis: {"counter": 2, "user_id": "ferris"}
// Step 6: GET index, including session cookie #2 in request
// - response should be: {"counter": 2, "user_id": "ferris"}
// Step 7: POST again to do_something, including session cookie #2 in request
// - updates session state in redis: {"counter": 3, "user_id": "ferris"}
// - response should be: {"counter": 3, "user_id": "ferris"}
// Step 8: GET index, including session cookie #1 in request
// - set-cookie actix-session should NOT be in response (session data is empty)
// - response should be: {"counter": 0, "user_id": None}
// Step 9: POST to logout, including session cookie #2
// - set-cookie actix-session will be in response with session cookie #2
// invalidation logic
// Step 10: GET index, including session cookie #2 in request
// - set-cookie actix-session should NOT be in response (session data is empty)
// - response should be: {"counter": 0, "user_id": None}
let srv = actix_test::start(|| {
App::new()
.wrap(RedisSession::new("127.0.0.1:6379", &[0; 32]).cookie_name("test-session"))
.wrap(middleware::Logger::default())
.service(resource("/").route(get().to(index)))
.service(resource("/do_something").route(post().to(do_something)))
.service(resource("/login").route(post().to(login)))
.service(resource("/logout").route(post().to(logout)))
});
// Step 1: GET index
// - set-cookie actix-session should NOT be in response (session data is empty)
// - response should be: {"counter": 0, "user_id": None}
let req_1a = srv.get("/").send();
let mut resp_1 = req_1a.await.unwrap();
assert!(resp_1.cookies().unwrap().is_empty());
let result_1 = resp_1.json::<IndexResponse>().await.unwrap();
assert_eq!(
result_1,
IndexResponse {
user_id: None,
counter: 0
}
);
// Step 2: POST to do_something
// - adds new session state in redis: {"counter": 1}
// - set-cookie actix-session should be in response (session cookie #1)
// - response should be: {"counter": 1, "user_id": None}
let req_2 = srv.post("/do_something").send();
let resp_2 = req_2.await.unwrap();
let cookie_1 = resp_2
.cookies()
.unwrap()
.clone()
.into_iter()
.find(|c| c.name() == "test-session")
.unwrap();
assert_eq!(cookie_1.max_age(), Some(Duration::days(7)));
// Step 3: GET index, including session cookie #1 in request
// - set-cookie will *not* be in response
// - response should be: {"counter": 1, "user_id": None}
let req_3 = srv.get("/").cookie(cookie_1.clone()).send();
let mut resp_3 = req_3.await.unwrap();
assert!(resp_3.cookies().unwrap().is_empty());
let result_3 = resp_3.json::<IndexResponse>().await.unwrap();
assert_eq!(
result_3,
IndexResponse {
user_id: None,
counter: 1
}
);
// Step 4: POST again to do_something, including session cookie #1 in request
// - updates session state in redis: {"counter": 2}
// - response should be: {"counter": 2, "user_id": None}
let req_4 = srv.post("/do_something").cookie(cookie_1.clone()).send();
let mut resp_4 = req_4.await.unwrap();
let result_4 = resp_4.json::<IndexResponse>().await.unwrap();
assert_eq!(
result_4,
IndexResponse {
user_id: None,
counter: 2
}
);
// Step 5: POST to login, including session cookie #1 in request
// - set-cookie actix-session will be in response (session cookie #2)
// - updates session state in redis: {"counter": 2, "user_id": "ferris"}
let req_5 = srv
.post("/login")
.cookie(cookie_1.clone())
.send_json(&json!({"user_id": "ferris"}));
let mut resp_5 = req_5.await.unwrap();
let cookie_2 = resp_5
.cookies()
.unwrap()
.clone()
.into_iter()
.find(|c| c.name() == "test-session")
.unwrap();
assert_ne!(cookie_1.value(), cookie_2.value());
let result_5 = resp_5.json::<IndexResponse>().await.unwrap();
assert_eq!(
result_5,
IndexResponse {
user_id: Some("ferris".into()),
counter: 2
}
);
// Step 6: GET index, including session cookie #2 in request
// - response should be: {"counter": 2, "user_id": "ferris"}
let req_6 = srv.get("/").cookie(cookie_2.clone()).send();
let mut resp_6 = req_6.await.unwrap();
let result_6 = resp_6.json::<IndexResponse>().await.unwrap();
assert_eq!(
result_6,
IndexResponse {
user_id: Some("ferris".into()),
counter: 2
}
);
// Step 7: POST again to do_something, including session cookie #2 in request
// - updates session state in redis: {"counter": 3, "user_id": "ferris"}
// - response should be: {"counter": 3, "user_id": "ferris"}
let req_7 = srv.post("/do_something").cookie(cookie_2.clone()).send();
let mut resp_7 = req_7.await.unwrap();
let result_7 = resp_7.json::<IndexResponse>().await.unwrap();
assert_eq!(
result_7,
IndexResponse {
user_id: Some("ferris".into()),
counter: 3
}
);
// Step 8: GET index, including session cookie #1 in request
// - set-cookie actix-session should NOT be in response (session data is empty)
// - response should be: {"counter": 0, "user_id": None}
let req_8 = srv.get("/").cookie(cookie_1.clone()).send();
let mut resp_8 = req_8.await.unwrap();
assert!(resp_8.cookies().unwrap().is_empty());
let result_8 = resp_8.json::<IndexResponse>().await.unwrap();
assert_eq!(
result_8,
IndexResponse {
user_id: None,
counter: 0
}
);
// Step 9: POST to logout, including session cookie #2
// - set-cookie actix-session will be in response with session cookie #2
// invalidation logic
let req_9 = srv.post("/logout").cookie(cookie_2.clone()).send();
let resp_9 = req_9.await.unwrap();
let cookie_3 = resp_9
.cookies()
.unwrap()
.clone()
.into_iter()
.find(|c| c.name() == "test-session")
.unwrap();
assert_ne!(
OffsetDateTime::now_utc().year(),
cookie_3
.expires()
.map(|t| t.datetime().expect("Expiration is a datetime").year())
.unwrap()
);
// Step 10: GET index, including session cookie #2 in request
// - set-cookie actix-session should NOT be in response (session data is empty)
// - response should be: {"counter": 0, "user_id": None}
let req_10 = srv.get("/").cookie(cookie_2.clone()).send();
let mut resp_10 = req_10.await.unwrap();
assert!(resp_10.cookies().unwrap().is_empty());
let result_10 = resp_10.json::<IndexResponse>().await.unwrap();
assert_eq!(
result_10,
IndexResponse {
user_id: None,
counter: 0
}
);
}
#[actix_web::test]
async fn test_max_age_session_only() {
//
// Test that removing max_age results in a session-only cookie
//
let srv = actix_test::start(|| {
App::new()
.wrap(
RedisSession::new("127.0.0.1:6379", &[0; 32])
.cookie_name("test-session")
.cookie_max_age(None),
)
.wrap(middleware::Logger::default())
.service(resource("/do_something").route(post().to(do_something)))
});
let req = srv.post("/do_something").send();
let resp = req.await.unwrap();
let cookie = resp
.cookies()
.unwrap()
.clone()
.into_iter()
.find(|c| c.name() == "test-session")
.unwrap();
assert_eq!(cookie.max_age(), None);
}
}

View File

@ -0,0 +1,42 @@
#[macro_use]
extern crate redis_async;
use actix_redis::{Command, Error, RedisActor, RespValue};
#[actix_web::test]
async fn test_error_connect() {
let addr = RedisActor::start("localhost:54000");
let _addr2 = addr.clone();
let res = addr.send(Command(resp_array!["GET", "test"])).await;
match res {
Ok(Err(Error::NotConnected)) => (),
_ => panic!("Should not happen {:?}", res),
}
}
#[actix_web::test]
async fn test_redis() {
env_logger::init();
let addr = RedisActor::start("127.0.0.1:6379");
let res = addr
.send(Command(resp_array!["SET", "test", "value"]))
.await;
match res {
Ok(Ok(resp)) => {
assert_eq!(resp, RespValue::SimpleString("OK".to_owned()));
let res = addr.send(Command(resp_array!["GET", "test"])).await;
match res {
Ok(Ok(resp)) => {
println!("RESP: {:?}", resp);
assert_eq!(resp, RespValue::BulkString((&b"value"[..]).into()));
}
_ => panic!("Should not happen {:?}", res),
}
}
_ => panic!("Should not happen {:?}", res),
}
}

View File

@ -1,138 +1,41 @@
# Changes
## Unreleased
## Unreleased - 2021-xx-xx
- Add `Session::contains_key` method.
- Add `Session::update[_or]()` methods.
- Update `redis` dependency to `0.29`.
## 0.10.1
- Expose `storage::generate_session_key()` without needing to enable a crate feature.
## 0.10.0
- Add `redis-session-rustls` crate feature that enables `rustls`-secured Redis sessions.
- Add `redis-pool` crate feature (off-by-default) which enables `RedisSessionStore::{new, builder}_pooled()` constructors.
- Rename `redis-rs-session` crate feature to `redis-session`.
- Rename `redis-rs-tls-session` crate feature to `redis-session-native-tls`.
- Remove `redis-actor-session` crate feature (and, therefore, the `actix-redis` based storage backend).
- Expose `storage::generate_session_key()`.
- Update `redis` dependency to `0.26`.
## 0.9.0
- Remove use of `async-trait` on `SessionStore` trait.
- Minimum supported Rust version (MSRV) is now 1.75.
## 0.8.0
- Set secure attribute when adding a session removal cookie.
- Update `redis` dependency to `0.23`.
- Minimum supported Rust version (MSRV) is now 1.68.
## 0.7.2
- Set SameSite attribute when adding a session removal cookie. [#284]
- Minimum supported Rust version (MSRV) is now 1.59 due to transitive `time` dependency.
[#284]: https://github.com/actix/actix-extras/pull/284
## 0.7.1
- Fix interaction between session state changes and renewal. [#265]
[#265]: https://github.com/actix/actix-extras/pull/265
## 0.7.0
- 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
- Implement `SessionExt` for `GuardContext`. [#234]
- `RedisSessionStore` will prevent connection timeouts from causing user-visible errors. [#235]
- Do not leak internal implementation details to callers when errors occur. [#236]
[#234]: https://github.com/actix/actix-extras/pull/234
[#236]: https://github.com/actix/actix-extras/pull/236
[#235]: https://github.com/actix/actix-extras/pull/235
## 0.6.1
- No significant changes since `0.6.0`.
## 0.6.0
### Added
- `SessionMiddleware`, a middleware to provide support for saving/updating/deleting session state against a pluggable storage backend (see `SessionStore` trait). [#212]
- `CookieSessionStore`, a cookie-based backend to store session state. [#212]
- `RedisActorSessionStore`, a Redis-based backend to store session state powered by `actix-redis`. [#212]
- `RedisSessionStore`, a Redis-based backend to store session state powered by `redis-rs`. [#212]
- Add TLS support for Redis via `RedisSessionStore`. [#212]
- Implement `SessionExt` for `ServiceResponse`. [#212]
### Changed
- Rename `UserSession` to `SessionExt`. [#212]
### Removed
- `CookieSession`; replaced with `CookieSessionStore`, a storage backend for `SessionMiddleware`. [#212]
- `Session::set_session`; use `Session::insert` to modify the session state. [#212]
[#212]: https://github.com/actix/actix-extras/pull/212
## 0.5.0
## 0.5.0 - 2022-03-01
- Update `actix-web` dependency to `4`.
## 0.5.0-beta.8
## 0.5.0-beta.8 - 2022-02-07
- Update `actix-web` dependency to `4.0.0-rc.1`.
## 0.5.0-beta.7
## 0.5.0-beta.7 - 2021-12-29
- Update `actix-web` dependency to `4.0.0.beta-18`. [#218]
- Minimum supported Rust version (MSRV) is now 1.54.
[#218]: https://github.com/actix/actix-extras/pull/218
## 0.5.0-beta.6
## 0.5.0-beta.6 - 2021-12-18
- Update `actix-web` dependency to `4.0.0.beta-15`. [#216]
[#216]: https://github.com/actix/actix-extras/pull/216
## 0.5.0-beta.5
## 0.5.0-beta.5 - 2021-12-12
- Update `actix-web` dependency to `4.0.0.beta-14`. [#209]
- Remove `UserSession` implementation for `RequestHead`. [#209]
- A session will be created in the storage backend if and only if there is some data inside the session state. This reduces the performance impact of `SessionMiddleware` on routes that do not leverage sessions. [#207]
[#207]: https://github.com/actix/actix-extras/pull/207
[#209]: https://github.com/actix/actix-extras/pull/209
## 0.5.0-beta.4
## 0.5.0-beta.4 - 2021-11-22
- No significant changes since `0.5.0-beta.3`.
## 0.5.0-beta.3
## 0.5.0-beta.3 - 2021-10-21
- Impl `Clone` for `CookieSession`. [#201]
- Update `actix-web` dependency to v4.0.0-beta.10. [#203]
- Minimum supported Rust version (MSRV) is now 1.52.
@ -140,12 +43,12 @@
[#201]: https://github.com/actix/actix-extras/pull/201
[#203]: https://github.com/actix/actix-extras/pull/203
## 0.5.0-beta.2
## 0.5.0-beta.2 - 2021-06-27
- No notable changes.
## 0.5.0-beta.1
## 0.5.0-beta.1 - 2021-04-02
- Add `Session::entries`. [#170]
- Rename `Session::{set => insert}` to match standard hash map naming. [#170]
- Return values from `Session::remove`. [#170]
@ -157,21 +60,21 @@
[#170]: https://github.com/actix/actix-extras/pull/170
## 0.4.1
## 0.4.1 - 2021-03-21
- `Session::set_session` takes a `IntoIterator` instead of `Iterator`. [#105]
- Fix calls to `session.purge()` from paths other than the one specified in the cookie. [#129]
[#105]: https://github.com/actix/actix-extras/pull/105
[#129]: https://github.com/actix/actix-extras/pull/129
## 0.4.0
## 0.4.0 - 2020-09-11
- Update `actix-web` dependency to 3.0.0.
- Minimum supported Rust version (MSRV) is now 1.42.0.
## 0.4.0-alpha.1
## 0.4.0-alpha.1 - 2020-03-14
- Update the `time` dependency to 0.2.7
- Update the `actix-web` dependency to 3.0.0-alpha.1
- Long lasting auto-prolonged session [#1292]
@ -179,62 +82,65 @@
[#1292]: https://github.com/actix/actix-web/pull/1292
## 0.3.0 - 2019-12-20
## 0.3.0 - 2019-12-20
- Release
## 0.3.0-alpha.4 - 2019-12-xx
## 0.3.0-alpha.4 - 2019-12-xx
- Allow access to sessions also from not mutable references to the request
## 0.3.0-alpha.3 - 2019-12-xx
## 0.3.0-alpha.3 - 2019-12-xx
- Add access to the session from RequestHead for use of session from guard methods
- Migrate to `std::future`
- Migrate to `actix-web` 2.0
## 0.2.0 - 2019-07-08
- Enhanced `actix-session` to facilitate state changes. Use `Session.renew()` at successful login to cycle a session (new key/cookie but keeps state). Use `Session.purge()` at logout to invalid a session cookie (and remove from redis cache, if applicable).
## 0.2.0 - 2019-07-08
- Enhanced ``actix-session`` to facilitate state changes. Use ``Session.renew()``
at successful login to cycle a session (new key/cookie but keeps state).
Use ``Session.purge()`` at logout to invalid a session cookie (and remove
from redis cache, if applicable).
## 0.1.1 - 2019-06-03
- Fix optional cookie session support
## 0.1.0 - 2019-05-18
## 0.1.0 - 2019-05-18
- Use actix-web 1.0.0-rc
## 0.1.0-beta.4 - 2019-05-12
## 0.1.0-beta.4 - 2019-05-12
- Use actix-web 1.0.0-beta.4
## 0.1.0-beta.2 - 2019-04-28
## 0.1.0-beta.2 - 2019-04-28
- Add helper trait `UserSession` which allows to get session for ServiceRequest and HttpRequest
## 0.1.0-beta.1 - 2019-04-20
## 0.1.0-beta.1 - 2019-04-20
- Update actix-web to beta.1
- `CookieSession::max_age()` accepts value in seconds
## 0.1.0-alpha.6 - 2019-04-14
## 0.1.0-alpha.6 - 2019-04-14
- Update actix-web alpha.6
## 0.1.0-alpha.4 - 2019-04-08
## 0.1.0-alpha.4 - 2019-04-08
- Update actix-web
## 0.1.0-alpha.3 - 2019-04-02
- Update actix-web
## 0.1.0-alpha.2 - 2019-03-29
## 0.1.0-alpha.2 - 2019-03-29
- Update actix-web
- Use new feature name for secure cookies
## 0.1.0-alpha.1 - 2019-03-28
## 0.1.0-alpha.1 - 2019-03-28
- Initial impl

View File

@ -1,60 +1,33 @@
[package]
name = "actix-session"
version = "0.10.1"
authors = [
"Nikolay Kim <fafhrd91@gmail.com>",
"Luca Palmieri <rust@lpalmieri.com>",
]
description = "Session management for Actix Web"
version = "0.5.0"
authors = ["Nikolay Kim <fafhrd91@gmail.com>"]
description = "Sessions for Actix Web"
keywords = ["http", "web", "framework", "async", "session"]
repository.workspace = true
homepage.workspace = true
license.workspace = true
edition.workspace = true
rust-version.workspace = true
homepage = "https://actix.rs"
repository = "https://github.com/actix/actix-extras.git"
license = "MIT OR Apache-2.0"
edition = "2018"
[package.metadata.docs.rs]
rustdoc-args = ["--cfg", "docsrs"]
all-features = true
[lib]
name = "actix_session"
path = "src/lib.rs"
[features]
default = []
cookie-session = []
redis-session = ["dep:redis"]
redis-session-native-tls = ["redis-session", "redis/tokio-native-tls-comp"]
redis-session-rustls = ["redis-session", "redis/tokio-rustls-comp"]
redis-pool = ["dep:deadpool-redis"]
default = ["cookie-session"]
cookie-session = ["actix-web/secure-cookies"]
[dependencies]
actix-service = "2"
actix-utils = "3"
actix-web = { version = "4", default-features = false, features = ["cookies", "secure-cookies"] }
actix-web = { version = "4", default_features = false, features = ["cookies"] }
anyhow = "1"
derive_more = { version = "2", features = ["display", "error", "from"] }
rand = "0.9"
serde = { version = "1" }
serde_json = { version = "1" }
tracing = { version = "0.1.30", default-features = false, features = ["log"] }
# redis-session
redis = { version = "0.29", default-features = false, features = ["tokio-comp", "connection-manager"], optional = true }
deadpool-redis = { version = "0.20", optional = true }
derive_more = "0.99.5"
futures-util = { version = "0.3.7", default-features = false }
log = "0.4"
serde = "1.0"
serde_json = "1.0"
time = "0.3"
[dev-dependencies]
actix-session = { path = ".", features = ["cookie-session", "redis-session"] }
actix-test = "0.1"
actix-web = { version = "4", default-features = false, features = ["cookies", "secure-cookies", "macros"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing = "0.1.30"
[lints]
workspace = true
[[example]]
name = "basic"
required-features = ["redis-session"]
[[example]]
name = "authentication"
required-features = ["redis-session"]
actix-web = { version = "4", default_features = false, features = ["macros", "cookies"] }

View File

@ -1,125 +1,15 @@
# actix-session
> Session management for Actix Web.
<!-- prettier-ignore-start -->
> Sessions for Actix Web.
[![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.10.1)](https://docs.rs/actix-session/0.10.1)
[![Documentation](https://docs.rs/actix-session/badge.svg?version=0.5.0)](https://docs.rs/actix-session/0.5.0)
![Apache 2.0 or MIT licensed](https://img.shields.io/crates/l/actix-session)
[![Dependency Status](https://deps.rs/crate/actix-session/0.10.1/status.svg)](https://deps.rs/crate/actix-session/0.10.1)
[![Dependency Status](https://deps.rs/crate/actix-session/0.5.0/status.svg)](https://deps.rs/crate/actix-session/0.5.0)
<!-- prettier-ignore-end -->
<!-- cargo-rdme start -->
## Documentation & Resources
Session management for Actix Web.
The HTTP protocol, at a first glance, is stateless: the client sends a request, the server parses its content, performs some processing and returns a response. The outcome is only influenced by the provided inputs (i.e. the request content) and whatever state the server queries while performing its processing.
Stateless systems are easier to reason about, but they are not quite as powerful as we need them to be - e.g. how do you authenticate a user? The user would be forced to authenticate **for every single request**. That is, for example, how 'Basic' Authentication works. While it may work for a machine user (i.e. an API client), it is impractical for a person—you do not want a login prompt on every single page you navigate to!
There is a solution - **sessions**. Using sessions the server can attach state to a set of requests coming from the same client. They are built on top of cookies - the server sets a cookie in the HTTP response (`Set-Cookie` header), the client (e.g. the browser) will store the cookie and play it back to the server when sending new requests (using the `Cookie` header).
We refer to the cookie used for sessions as a **session cookie**. Its content is called **session key** (or **session ID**), while the state attached to the session is referred to as **session state**.
`actix-session` provides an easy-to-use framework to manage sessions in applications built on top of Actix Web. [`SessionMiddleware`] is the middleware underpinning the functionality provided by `actix-session`; it takes care of all the session cookie handling and instructs the **storage backend** to create/delete/update the session state based on the operations performed against the active [`Session`].
`actix-session` provides some built-in storage backends: ([`CookieSessionStore`], [`RedisSessionStore`]) - you can create a custom storage backend by implementing the [`SessionStore`] trait.
Further reading on sessions:
- [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265);
- [OWASP's session management cheat-sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html).
## Getting started
To start using sessions in your Actix Web application you must register [`SessionMiddleware`] as a middleware on your `App`:
```rust
use actix_web::{web, App, HttpServer, HttpResponse, Error};
use actix_session::{Session, SessionMiddleware, storage::RedisSessionStore};
use actix_web::cookie::Key;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// When using `Key::generate()` it is important to initialize outside of the
// `HttpServer::new` closure. When deployed the secret key should be read from a
// configuration file or environment variables.
let secret_key = Key::generate();
let redis_store = RedisSessionStore::new("redis://127.0.0.1:6379")
.await
.unwrap();
HttpServer::new(move ||
App::new()
// Add session management to your application using Redis for session state storage
.wrap(
SessionMiddleware::new(
redis_store.clone(),
secret_key.clone(),
)
)
.default_service(web::to(|| HttpResponse::Ok())))
.bind(("127.0.0.1", 8080))?
.run()
.await
}
```
The session state can be accessed and modified by your request handlers using the [`Session`] extractor. Note that this doesn't work in the stream of a streaming response.
```rust
use actix_web::Error;
use actix_session::Session;
fn index(session: Session) -> Result<&'static str, Error> {
// access the session state
if let Some(count) = session.get::<i32>("counter")? {
println!("SESSION value: {}", count);
// modify the session state
session.insert("counter", count + 1)?;
} else {
session.insert("counter", 1)?;
}
Ok("Welcome!")
}
```
## Choosing A Backend
By default, `actix-session` does not provide any storage backend to retrieve and save the state attached to your sessions. You can enable:
- a purely cookie-based "backend", [`CookieSessionStore`], using the `cookie-session` feature flag.
```console
cargo add actix-session --features=cookie-session
```
- a Redis-based backend via the [`redis`] crate, [`RedisSessionStore`], using the `redis-session` feature flag.
```console
cargo add actix-session --features=redis-session
```
Add the `redis-session-native-tls` feature flag if you want to connect to Redis using a secure connection (via the `native-tls` crate):
```console
cargo add actix-session --features=redis-session-native-tls
```
If you, instead, prefer depending on `rustls`, use the `redis-session-rustls` feature flag:
```console
cargo add actix-session --features=redis-session-rustls
```
You can implement your own session storage backend using the [`SessionStore`] trait.
[`SessionStore`]: storage::SessionStore
[`CookieSessionStore`]: storage::CookieSessionStore
[`RedisSessionStore`]: storage::RedisSessionStore
<!-- cargo-rdme end -->
- [API Documentation](https://docs.rs/actix-session)
- [Example Projects](https://github.com/actix/examples/tree/master/auth/cookie-session)
- Minimum Supported Rust Version (MSRV): 1.54

View File

@ -1,51 +0,0 @@
use actix_session::{storage::RedisSessionStore, Session, SessionMiddleware};
use actix_web::{cookie::Key, middleware, web, App, Error, HttpRequest, HttpServer, Responder};
use tracing::level_filters::LevelFilter;
use tracing_subscriber::EnvFilter;
/// simple handler
async fn index(req: HttpRequest, session: Session) -> Result<impl Responder, Error> {
println!("{req:?}");
// session
if let Some(count) = session.get::<i32>("counter")? {
println!("SESSION value: {count}");
session.insert("counter", count + 1)?;
} else {
session.insert("counter", 1)?;
}
Ok("Welcome!")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy(),
)
.init();
// The signing key would usually be read from a configuration file/environment variables.
let signing_key = Key::generate();
tracing::info!("setting up Redis session storage");
let storage = RedisSessionStore::new("127.0.0.1:6379").await.unwrap();
tracing::info!("starting HTTP server at http://localhost:8080");
HttpServer::new(move || {
App::new()
// enable logger
.wrap(middleware::Logger::default())
// cookie session middleware
.wrap(SessionMiddleware::new(storage.clone(), signing_key.clone()))
// register simple route, handle all methods
.service(web::resource("/").to(index))
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}

View File

@ -0,0 +1,66 @@
use std::{error::Error as StdError, fmt};
use actix_web::ResponseError;
use derive_more::Display;
#[derive(Debug, Display)]
pub(crate) enum InsertErrorKind {
#[display(fmt = "{}", _0)]
Json(serde_json::Error),
}
impl Into<actix_web::Error> for InsertErrorKind {
fn into(self) -> actix_web::Error {
match self {
InsertErrorKind::Json(err) => err.into(),
}
}
}
/// Error returned by [`Session::insert`][crate::Session::insert]. Allows access to value that
/// failed to be inserted.
pub struct InsertError<T> {
pub(crate) value: Option<T>,
pub(crate) error: InsertErrorKind,
}
impl<T> InsertError<T> {
/// Takes value out of error.
///
/// # Panics
/// Panics if called more than once.
pub fn take_value(&mut self) -> T {
self.value
.take()
.expect("take_value should only be called once")
}
}
impl<T> fmt::Debug for InsertError<T> {
fn fmt<'a>(&'a self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut dbg = f.debug_struct("SessionInsertError");
match &self.value {
Some(_) => dbg.field("value", &"Some([value])" as _),
None => dbg.field("value", &None::<()> as _),
};
dbg.field("error", &self.error).finish()
}
}
impl<T> fmt::Display for InsertError<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.error, f)
}
}
impl<T: fmt::Debug> StdError for InsertError<T> {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(match &self.error {
InsertErrorKind::Json(err) => err,
})
}
}
impl<T> ResponseError for InsertError<T> {}

View File

@ -1,397 +0,0 @@
//! Configuration options to tune the behaviour of [`SessionMiddleware`].
use actix_web::cookie::{time::Duration, Key, SameSite};
use derive_more::derive::From;
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, From)]
#[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),
}
/// 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.
///
/// Due to its `Into<SessionLifecycle>` implementation, a `BrowserSession` can be passed directly
/// to [`SessionMiddlewareBuilder::session_lifecycle()`].
#[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.
///
/// Due to its `Into<SessionLifecycle>` implementation, a `PersistentSession` can be passed directly
/// to [`SessionMiddlewareBuilder::session_lifecycle()`].
///
/// # Examples
/// ```
/// use actix_web::cookie::time::Duration;
/// use actix_session::SessionMiddleware;
/// use actix_session::config::{PersistentSession, TtlExtensionPolicy};
///
/// const SECS_IN_WEEK: i64 = 60 * 60 * 24 * 7;
///
/// // a session lifecycle with a time-to-live (expiry) of 1 week and default extension policy
/// PersistentSession::default().session_ttl(Duration::seconds(SECS_IN_WEEK));
///
/// // a session lifecycle with the default time-to-live (expiry) and a custom extension policy
/// PersistentSession::default()
/// // this policy causes the session state's TTL to be refreshed on every request
/// .session_ttl_extension_policy(TtlExtensionPolicy::OnEveryRequest);
/// ```
///
/// [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.
///
/// The session TTL is also used as the TTL for the session state in the storage backend.
///
/// Defaults to 1 day.
///
/// 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.
#[doc(alias = "max_age", alias = "max age", alias = "expires")]
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`].
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 on the storage backend.
#[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, customized [`SessionMiddleware`] builder.
#[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`].
///
/// # Examples
/// ```
/// use actix_web::cookie::{Key, time::Duration};
/// use actix_session::{SessionMiddleware, config::PersistentSession};
/// use actix_session::storage::CookieSessionStore;
///
/// const SECS_IN_WEEK: i64 = 60 * 60 * 24 * 7;
///
/// // creates a session middleware with a time-to-live (expiry) of 1 week
/// SessionMiddleware::builder(CookieSessionStore::default(), Key::from(&[0; 64]))
/// .session_lifecycle(
/// PersistentSession::default().session_ttl(Duration::seconds(SECS_IN_WEEK))
/// )
/// .build();
/// ```
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(),
}
}

562
actix-session/src/cookie.rs Normal file
View File

@ -0,0 +1,562 @@
//! Cookie based sessions. See docs for [`CookieSession`].
use std::{collections::HashMap, rc::Rc};
use actix_utils::future::{ok, Ready};
use actix_web::{
body::{EitherBody, MessageBody},
cookie::{Cookie, CookieJar, Key, SameSite},
dev::{Service, ServiceRequest, ServiceResponse, Transform},
http::header::{HeaderValue, SET_COOKIE},
Error, ResponseError,
};
use derive_more::Display;
use futures_util::future::{FutureExt as _, LocalBoxFuture};
use serde_json::error::Error as JsonError;
use time::{Duration, OffsetDateTime};
use crate::{Session, SessionStatus};
/// Errors that can occur during handling cookie session
#[derive(Debug, Display)]
pub enum CookieSessionError {
/// Size of the serialized session is greater than 4000 bytes.
#[display(fmt = "Size of the serialized session is greater than 4000 bytes.")]
Overflow,
/// Fail to serialize session.
#[display(fmt = "Fail to serialize session")]
Serialize(JsonError),
}
impl ResponseError for CookieSessionError {}
#[derive(Copy, Clone)]
enum CookieSecurity {
Signed,
Private,
}
#[derive(Clone)]
struct CookieSessionInner {
key: Key,
security: CookieSecurity,
name: String,
path: String,
domain: Option<String>,
lazy: bool,
secure: bool,
http_only: bool,
max_age: Option<Duration>,
expires_in: Option<Duration>,
same_site: Option<SameSite>,
}
impl CookieSessionInner {
fn new(key: &[u8], security: CookieSecurity) -> CookieSessionInner {
CookieSessionInner {
security,
key: Key::derive_from(key),
name: "actix-session".to_owned(),
path: "/".to_owned(),
domain: None,
lazy: false,
secure: true,
http_only: true,
max_age: None,
expires_in: None,
same_site: None,
}
}
fn set_cookie<B>(
&self,
res: &mut ServiceResponse<B>,
state: impl Iterator<Item = (String, String)>,
) -> Result<(), Error> {
let state: HashMap<String, String> = state.collect();
if self.lazy && state.is_empty() {
return Ok(());
}
let value = serde_json::to_string(&state).map_err(CookieSessionError::Serialize)?;
if value.len() > 4064 {
return Err(CookieSessionError::Overflow.into());
}
let mut cookie = Cookie::new(self.name.clone(), value);
cookie.set_path(self.path.clone());
cookie.set_secure(self.secure);
cookie.set_http_only(self.http_only);
if let Some(ref domain) = self.domain {
cookie.set_domain(domain.clone());
}
if let Some(expires_in) = self.expires_in {
cookie.set_expires(OffsetDateTime::now_utc() + expires_in);
}
if let Some(max_age) = self.max_age {
cookie.set_max_age(max_age);
}
if let Some(same_site) = self.same_site {
cookie.set_same_site(same_site);
}
let mut jar = CookieJar::new();
match self.security {
CookieSecurity::Signed => jar.signed_mut(&self.key).add(cookie),
CookieSecurity::Private => jar.private_mut(&self.key).add(cookie),
}
for cookie in jar.delta() {
let val = HeaderValue::from_str(&cookie.encoded().to_string())?;
res.headers_mut().append(SET_COOKIE, val);
}
Ok(())
}
/// invalidates session cookie
fn remove_cookie<B>(&self, res: &mut ServiceResponse<B>) -> Result<(), Error> {
let mut cookie = Cookie::named(self.name.clone());
cookie.set_path(self.path.clone());
cookie.set_value("");
cookie.set_max_age(Duration::ZERO);
cookie.set_expires(OffsetDateTime::now_utc() - Duration::days(365));
let val = HeaderValue::from_str(&cookie.to_string())?;
res.headers_mut().append(SET_COOKIE, val);
Ok(())
}
fn load(&self, req: &ServiceRequest) -> (bool, HashMap<String, String>) {
if let Ok(cookies) = req.cookies() {
for cookie in cookies.iter() {
if cookie.name() == self.name {
let mut jar = CookieJar::new();
jar.add_original(cookie.clone());
let cookie_opt = match self.security {
CookieSecurity::Signed => jar.signed(&self.key).get(&self.name),
CookieSecurity::Private => jar.private(&self.key).get(&self.name),
};
if let Some(cookie) = cookie_opt {
if let Ok(val) = serde_json::from_str(cookie.value()) {
return (false, val);
}
}
}
}
}
(true, HashMap::new())
}
}
/// Use cookies for session storage.
///
/// `CookieSession` creates sessions which are limited to storing
/// fewer than 4000 bytes of data (as the payload must fit into a single
/// cookie). An Internal Server Error is generated if the session contains more
/// than 4000 bytes.
///
/// A cookie may have a security policy of *signed* or *private*. Each has a
/// respective `CookieSession` constructor.
///
/// A *signed* cookie is stored on the client as plaintext alongside
/// a signature such that the cookie may be viewed but not modified by the
/// client.
///
/// A *private* cookie is stored on the client as encrypted text
/// such that it may neither be viewed nor modified by the client.
///
/// The constructors take a key as an argument.
/// This is the private key for cookie session - when this value is changed,
/// all session data is lost. The constructors will panic if the key is less
/// than 32 bytes in length.
///
/// The backend relies on `cookie` crate to create and read cookies.
/// By default all cookies are percent encoded, but certain symbols may
/// cause troubles when reading cookie, if they are not properly percent encoded.
///
/// # Examples
/// ```
/// use actix_session::CookieSession;
/// use actix_web::{web, App, HttpResponse, HttpServer};
///
/// let app = App::new().wrap(
/// CookieSession::signed(&[0; 32])
/// .domain("www.rust-lang.org")
/// .name("actix_session")
/// .path("/")
/// .secure(true))
/// .service(web::resource("/").to(|| HttpResponse::Ok()));
/// ```
#[derive(Clone)]
pub struct CookieSession(Rc<CookieSessionInner>);
impl CookieSession {
/// Construct new *signed* `CookieSession` instance.
///
/// Panics if key length is less than 32 bytes.
pub fn signed(key: &[u8]) -> CookieSession {
CookieSession(Rc::new(CookieSessionInner::new(
key,
CookieSecurity::Signed,
)))
}
/// Construct new *private* `CookieSession` instance.
///
/// Panics if key length is less than 32 bytes.
pub fn private(key: &[u8]) -> CookieSession {
CookieSession(Rc::new(CookieSessionInner::new(
key,
CookieSecurity::Private,
)))
}
/// Sets the `path` field in the session cookie being built.
pub fn path<S: Into<String>>(mut self, value: S) -> CookieSession {
Rc::get_mut(&mut self.0).unwrap().path = value.into();
self
}
/// Sets the `name` field in the session cookie being built.
pub fn name<S: Into<String>>(mut self, value: S) -> CookieSession {
Rc::get_mut(&mut self.0).unwrap().name = value.into();
self
}
/// Sets the `domain` field in the session cookie being built.
pub fn domain<S: Into<String>>(mut self, value: S) -> CookieSession {
Rc::get_mut(&mut self.0).unwrap().domain = Some(value.into());
self
}
/// When true, prevents adding session cookies to responses until
/// the session contains data. Default is `false`.
///
/// Useful when trying to comply with laws that require consent for setting cookies.
pub fn lazy(mut self, value: bool) -> CookieSession {
Rc::get_mut(&mut self.0).unwrap().lazy = value;
self
}
/// Sets the `secure` field in the session cookie being built.
///
/// If the `secure` field is set, a cookie will only be transmitted when the
/// connection is secure - i.e. `https`
pub fn secure(mut self, value: bool) -> CookieSession {
Rc::get_mut(&mut self.0).unwrap().secure = value;
self
}
/// Sets the `http_only` field in the session cookie being built.
pub fn http_only(mut self, value: bool) -> CookieSession {
Rc::get_mut(&mut self.0).unwrap().http_only = value;
self
}
/// Sets the `same_site` field in the session cookie being built.
pub fn same_site(mut self, value: SameSite) -> CookieSession {
Rc::get_mut(&mut self.0).unwrap().same_site = Some(value);
self
}
/// Sets the `max-age` field in the session cookie being built.
pub fn max_age(self, seconds: i64) -> CookieSession {
self.max_age_time(Duration::seconds(seconds))
}
/// Sets the `max-age` field in the session cookie being built.
pub fn max_age_time(mut self, value: time::Duration) -> CookieSession {
Rc::get_mut(&mut self.0).unwrap().max_age = Some(value);
self
}
/// Sets the `expires` field in the session cookie being built.
pub fn expires_in(self, seconds: i64) -> CookieSession {
self.expires_in_time(Duration::seconds(seconds))
}
/// Sets the `expires` field in the session cookie being built.
pub fn expires_in_time(mut self, value: Duration) -> CookieSession {
Rc::get_mut(&mut self.0).unwrap().expires_in = Some(value);
self
}
}
impl<S, B> Transform<S, ServiceRequest> for CookieSession
where
S: Service<ServiceRequest, Response = ServiceResponse<B>>,
S::Future: 'static,
S::Error: 'static,
B: MessageBody + 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = S::Error;
type InitError = ();
type Transform = CookieSessionMiddleware<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ok(CookieSessionMiddleware {
service,
inner: self.0.clone(),
})
}
}
/// Cookie based session middleware.
pub struct CookieSessionMiddleware<S> {
service: S,
inner: Rc<CookieSessionInner>,
}
impl<S, B> Service<ServiceRequest> for CookieSessionMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>>,
S::Future: 'static,
S::Error: 'static,
B: MessageBody + 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = S::Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
actix_service::forward_ready!(service);
/// On first request, a new session cookie is returned in response, regardless
/// of whether any session state is set. With subsequent requests, if the
/// session state changes, then set-cookie is returned in response. As
/// a user logs out, call session.purge() to set SessionStatus accordingly
/// and this will trigger removal of the session cookie in the response.
fn call(&self, mut req: ServiceRequest) -> Self::Future {
let inner = self.inner.clone();
let (is_new, state) = self.inner.load(&req);
let prolong_expiration = self.inner.expires_in.is_some();
Session::set_session(&mut req, state);
let fut = self.service.call(req);
async move {
let mut res = fut.await?;
let result = match Session::get_changes(&mut res) {
(SessionStatus::Changed, state) | (SessionStatus::Renewed, state) => {
inner.set_cookie(&mut res, state)
}
(SessionStatus::Unchanged, state) if prolong_expiration => {
inner.set_cookie(&mut res, state)
}
// set a new session cookie upon first request (new client)
(SessionStatus::Unchanged, _) => {
if is_new {
let state: HashMap<String, String> = HashMap::new();
inner.set_cookie(&mut res, state.into_iter())
} else {
Ok(())
}
}
(SessionStatus::Purged, _) => {
let _ = inner.remove_cookie(&mut res);
Ok(())
}
};
match result {
Ok(()) => Ok(res.map_into_left_body()),
Err(error) => Ok(res.error_response(error).map_into_right_body()),
}
}
.boxed_local()
}
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::web::Bytes;
use actix_web::{test, web, App};
#[actix_web::test]
async fn cookie_session() {
let app = test::init_service(
App::new()
.wrap(CookieSession::signed(&[0; 32]).secure(false))
.service(web::resource("/").to(|ses: Session| async move {
let _ = ses.insert("counter", 100);
"test"
})),
)
.await;
let request = test::TestRequest::get().to_request();
let response = app.call(request).await.unwrap();
assert!(response
.response()
.cookies()
.any(|c| c.name() == "actix-session"));
}
#[actix_web::test]
async fn private_cookie() {
let app = test::init_service(
App::new()
.wrap(CookieSession::private(&[0; 32]).secure(false))
.service(web::resource("/").to(|ses: Session| async move {
let _ = ses.insert("counter", 100);
"test"
})),
)
.await;
let request = test::TestRequest::get().to_request();
let response = app.call(request).await.unwrap();
assert!(response
.response()
.cookies()
.any(|c| c.name() == "actix-session"));
}
#[actix_web::test]
async fn lazy_cookie() {
let app = test::init_service(
App::new()
.wrap(CookieSession::signed(&[0; 32]).secure(false).lazy(true))
.service(web::resource("/count").to(|ses: Session| async move {
let _ = ses.insert("counter", 100);
"counting"
}))
.service(web::resource("/").to(|_ses: Session| async move { "test" })),
)
.await;
let request = test::TestRequest::get().to_request();
let response = app.call(request).await.unwrap();
assert!(response.response().cookies().count() == 0);
let request = test::TestRequest::with_uri("/count").to_request();
let response = app.call(request).await.unwrap();
assert!(response
.response()
.cookies()
.any(|c| c.name() == "actix-session"));
}
#[actix_web::test]
async fn cookie_session_extractor() {
let app = test::init_service(
App::new()
.wrap(CookieSession::signed(&[0; 32]).secure(false))
.service(web::resource("/").to(|ses: Session| async move {
let _ = ses.insert("counter", 100);
"test"
})),
)
.await;
let request = test::TestRequest::get().to_request();
let response = app.call(request).await.unwrap();
assert!(response
.response()
.cookies()
.any(|c| c.name() == "actix-session"));
}
#[actix_web::test]
async fn basics() {
let app = test::init_service(
App::new()
.wrap(
CookieSession::signed(&[0; 32])
.path("/test/")
.name("actix-test")
.domain("localhost")
.http_only(true)
.same_site(SameSite::Lax)
.max_age(100),
)
.service(web::resource("/").to(|ses: Session| async move {
let _ = ses.insert("counter", 100);
"test"
}))
.service(web::resource("/test/").to(|ses: Session| async move {
let val: usize = ses.get("counter").unwrap().unwrap();
format!("counter: {}", val)
})),
)
.await;
let request = test::TestRequest::get().to_request();
let response = app.call(request).await.unwrap();
let cookie = response
.response()
.cookies()
.find(|c| c.name() == "actix-test")
.unwrap()
.clone();
assert_eq!(cookie.path().unwrap(), "/test/");
let request = test::TestRequest::with_uri("/test/")
.cookie(cookie)
.to_request();
let body = test::call_and_read_body(&app, request).await;
assert_eq!(body, Bytes::from_static(b"counter: 100"));
}
#[actix_web::test]
async fn prolong_expiration() {
let app = test::init_service(
App::new()
.wrap(CookieSession::signed(&[0; 32]).secure(false).expires_in(60))
.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;
let request = test::TestRequest::get().to_request();
let response = app.call(request).await.unwrap();
let expires_1 = response
.response()
.cookies()
.find(|c| c.name() == "actix-session")
.expect("Cookie is set")
.expires()
.expect("Expiration is set")
.datetime()
.expect("Expiration is a datetime");
actix_web::rt::time::sleep(std::time::Duration::from_secs(1)).await;
let request = test::TestRequest::with_uri("/test/").to_request();
let response = app.call(request).await.unwrap();
let expires_2 = response
.response()
.cookies()
.find(|c| c.name() == "actix-session")
.expect("Cookie is set")
.expires()
.expect("Expiration is set")
.datetime()
.expect("Expiration is a datetime");
assert!(expires_2 - expires_1 >= Duration::seconds(1));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,463 +0,0 @@
use std::{collections::HashMap, fmt, future::Future, pin::Pin, rc::Rc};
use actix_utils::future::{ready, Ready};
use actix_web::{
body::MessageBody,
cookie::{Cookie, CookieJar, Key},
dev::{forward_ready, ResponseHead, Service, ServiceRequest, ServiceResponse, Transform},
http::header::{HeaderValue, SET_COOKIE},
HttpResponse,
};
use anyhow::Context;
use crate::{
config::{
self, Configuration, CookieConfiguration, CookieContentSecurity, SessionMiddlewareBuilder,
TtlExtensionPolicy,
},
storage::{LoadError, SessionKey, SessionStore},
Session, SessionStatus,
};
/// A middleware for session management in Actix Web applications.
///
/// [`SessionMiddleware`] takes care of a few jobs:
///
/// - Instructs the session storage backend to create/update/delete/retrieve the state attached to
/// a session according to its status and the operations that have been performed against it;
/// - Set/remove a cookie, on the client side, to enable a user to be consistently associated with
/// the same session across multiple HTTP requests.
///
/// Use [`SessionMiddleware::new`] to initialize the session framework using the default parameters.
/// To create a new instance of [`SessionMiddleware`] you need to provide:
///
/// - an instance of the session storage backend you wish to use (i.e. an implementation of
/// [`SessionStore`]);
/// - a secret key, to sign or encrypt the content of client-side session cookie.
///
/// # How did we choose defaults?
/// You should not regret adding `actix-session` to your dependencies and going to production using
/// the default configuration. That is why, when in doubt, we opt to use the most secure option for
/// each configuration parameter.
///
/// We expose knobs to change the default to suit your needs—i.e., if you know what you are doing,
/// we will not stop you. But being a subject-matter expert should not be a requirement to deploy
/// reasonably secure implementation of sessions.
///
/// # Examples
/// ```no_run
/// use actix_web::{web, App, HttpServer, HttpResponse, Error};
/// use actix_session::{Session, SessionMiddleware, storage::RedisSessionStore};
/// use actix_web::cookie::Key;
///
/// // The secret key would usually be read from a configuration file/environment variables.
/// fn get_secret_key() -> Key {
/// # todo!()
/// // [...]
/// }
///
/// #[actix_web::main]
/// async fn main() -> std::io::Result<()> {
/// let secret_key = get_secret_key();
/// let storage = RedisSessionStore::new("127.0.0.1:6379").await.unwrap();
///
/// HttpServer::new(move || {
/// App::new()
/// // Add session management to your application using Redis as storage
/// .wrap(SessionMiddleware::new(
/// storage.clone(),
/// secret_key.clone(),
/// ))
/// .default_service(web::to(|| HttpResponse::Ok()))
/// })
/// .bind(("127.0.0.1", 8080))?
/// .run()
/// .await
/// }
/// ```
///
/// If you want to customise use [`builder`](Self::builder) instead of [`new`](Self::new):
///
/// ```no_run
/// use actix_web::{App, cookie::{Key, time}, Error, HttpResponse, HttpServer, web};
/// use actix_session::{Session, SessionMiddleware, storage::RedisSessionStore};
/// use actix_session::config::PersistentSession;
///
/// // The secret key would usually be read from a configuration file/environment variables.
/// fn get_secret_key() -> Key {
/// # todo!()
/// // [...]
/// }
///
/// #[actix_web::main]
/// async fn main() -> std::io::Result<()> {
/// let secret_key = get_secret_key();
/// let storage = RedisSessionStore::new("127.0.0.1:6379").await.unwrap();
///
/// HttpServer::new(move || {
/// App::new()
/// // Customise session length!
/// .wrap(
/// SessionMiddleware::builder(storage.clone(), secret_key.clone())
/// .session_lifecycle(
/// PersistentSession::default().session_ttl(time::Duration::days(5)),
/// )
/// .build(),
/// )
/// .default_service(web::to(|| HttpResponse::Ok()))
/// })
/// .bind(("127.0.0.1", 8080))?
/// .run()
/// .await
/// }
/// ```
#[derive(Clone)]
pub struct SessionMiddleware<Store: SessionStore> {
storage_backend: Rc<Store>,
configuration: Rc<Configuration>,
}
impl<Store: SessionStore> SessionMiddleware<Store> {
/// Use [`SessionMiddleware::new`] to initialize the session framework using the default
/// parameters.
///
/// To create a new instance of [`SessionMiddleware`] you need to provide:
/// - an instance of the session storage backend you wish to use (i.e. an implementation of
/// [`SessionStore`]);
/// - a secret key, to sign or encrypt the content of client-side session cookie.
pub fn new(store: Store, key: Key) -> Self {
Self::builder(store, key).build()
}
/// A fluent API to configure [`SessionMiddleware`].
///
/// It takes as input the two required inputs to create a new instance of [`SessionMiddleware`]:
/// - an instance of the session storage backend you wish to use (i.e. an implementation of
/// [`SessionStore`]);
/// - a secret key, to sign or encrypt the content of client-side session cookie.
pub fn builder(store: Store, key: Key) -> SessionMiddlewareBuilder<Store> {
SessionMiddlewareBuilder::new(store, config::default_configuration(key))
}
pub(crate) fn from_parts(store: Store, configuration: Configuration) -> Self {
Self {
storage_backend: Rc::new(store),
configuration: Rc::new(configuration),
}
}
}
impl<S, B, Store> Transform<S, ServiceRequest> for SessionMiddleware<Store>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static,
S::Future: 'static,
B: MessageBody + 'static,
Store: SessionStore + 'static,
{
type Response = ServiceResponse<B>;
type Error = actix_web::Error;
type Transform = InnerSessionMiddleware<S, Store>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(InnerSessionMiddleware {
service: Rc::new(service),
configuration: Rc::clone(&self.configuration),
storage_backend: Rc::clone(&self.storage_backend),
}))
}
}
/// Short-hand to create an `actix_web::Error` instance that will result in an `Internal Server
/// Error` response while preserving the error root cause (e.g. in logs).
fn e500<E: fmt::Debug + fmt::Display + 'static>(err: E) -> actix_web::Error {
// We do not use `actix_web::error::ErrorInternalServerError` because we do not want to
// leak internal implementation details to the caller.
//
// `actix_web::error::ErrorInternalServerError` includes the error Display representation
// as body of the error responses, leading to messages like "There was an issue persisting
// the session state" reaching API clients. We don't want that, we want opaque 500s.
actix_web::error::InternalError::from_response(
err,
HttpResponse::InternalServerError().finish(),
)
.into()
}
#[doc(hidden)]
#[non_exhaustive]
pub struct InnerSessionMiddleware<S, Store: SessionStore + 'static> {
service: Rc<S>,
configuration: Rc<Configuration>,
storage_backend: Rc<Store>,
}
impl<S, B, Store> Service<ServiceRequest> for InnerSessionMiddleware<S, Store>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static,
S::Future: 'static,
Store: SessionStore + 'static,
{
type Response = ServiceResponse<B>;
type Error = actix_web::Error;
#[allow(clippy::type_complexity)]
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
forward_ready!(service);
fn call(&self, mut req: ServiceRequest) -> Self::Future {
let service = Rc::clone(&self.service);
let storage_backend = Rc::clone(&self.storage_backend);
let configuration = Rc::clone(&self.configuration);
Box::pin(async move {
let session_key = extract_session_key(&req, &configuration.cookie);
let (session_key, session_state) =
load_session_state(session_key, storage_backend.as_ref()).await?;
Session::set_session(&mut req, session_state);
let mut res = service.call(req).await?;
let (status, session_state) = Session::get_changes(&mut res);
match session_key {
None => {
// we do not create an entry in the session store if there is no state attached
// to a fresh session
if !session_state.is_empty() {
let session_key = storage_backend
.save(session_state, &configuration.session.state_ttl)
.await
.map_err(e500)?;
set_session_cookie(
res.response_mut().head_mut(),
session_key,
&configuration.cookie,
)
.map_err(e500)?;
}
}
Some(session_key) => {
match status {
SessionStatus::Changed => {
let session_key = storage_backend
.update(
session_key,
session_state,
&configuration.session.state_ttl,
)
.await
.map_err(e500)?;
set_session_cookie(
res.response_mut().head_mut(),
session_key,
&configuration.cookie,
)
.map_err(e500)?;
}
SessionStatus::Purged => {
storage_backend.delete(&session_key).await.map_err(e500)?;
delete_session_cookie(
res.response_mut().head_mut(),
&configuration.cookie,
)
.map_err(e500)?;
}
SessionStatus::Renewed => {
storage_backend.delete(&session_key).await.map_err(e500)?;
let session_key = storage_backend
.save(session_state, &configuration.session.state_ttl)
.await
.map_err(e500)?;
set_session_cookie(
res.response_mut().head_mut(),
session_key,
&configuration.cookie,
)
.map_err(e500)?;
}
SessionStatus::Unchanged => {
if matches!(
configuration.ttl_extension_policy,
TtlExtensionPolicy::OnEveryRequest
) {
storage_backend
.update_ttl(&session_key, &configuration.session.state_ttl)
.await
.map_err(e500)?;
if configuration.cookie.max_age.is_some() {
set_session_cookie(
res.response_mut().head_mut(),
session_key,
&configuration.cookie,
)
.map_err(e500)?;
}
}
}
};
}
}
Ok(res)
})
}
}
/// Examines the session cookie attached to the incoming request, if there is one, and tries
/// to extract the session key.
///
/// It returns `None` if there is no session cookie or if the session cookie is considered invalid
/// (e.g., when failing a signature check).
fn extract_session_key(req: &ServiceRequest, config: &CookieConfiguration) -> Option<SessionKey> {
let cookies = req.cookies().ok()?;
let session_cookie = cookies
.iter()
.find(|&cookie| cookie.name() == config.name)?;
let mut jar = CookieJar::new();
jar.add_original(session_cookie.clone());
let verification_result = match config.content_security {
CookieContentSecurity::Signed => jar.signed(&config.key).get(&config.name),
CookieContentSecurity::Private => jar.private(&config.key).get(&config.name),
};
if verification_result.is_none() {
tracing::warn!(
"The session cookie attached to the incoming request failed to pass cryptographic \
checks (signature verification/decryption)."
);
}
match verification_result?.value().to_owned().try_into() {
Ok(session_key) => Some(session_key),
Err(err) => {
tracing::warn!(
error.message = %err,
error.cause_chain = ?err,
"Invalid session key, ignoring."
);
None
}
}
}
async fn load_session_state<Store: SessionStore>(
session_key: Option<SessionKey>,
storage_backend: &Store,
) -> Result<(Option<SessionKey>, HashMap<String, String>), actix_web::Error> {
if let Some(session_key) = session_key {
match storage_backend.load(&session_key).await {
Ok(state) => {
if let Some(state) = state {
Ok((Some(session_key), state))
} else {
// We discard the existing session key given that the state attached to it can
// no longer be found (e.g. it expired or we suffered some data loss in the
// storage). Regenerating the session key will trigger the `save` workflow
// instead of the `update` workflow if the session state is modified during the
// lifecycle of the current request.
tracing::info!(
"No session state has been found for a valid session key, creating a new \
empty session."
);
Ok((None, HashMap::new()))
}
}
Err(err) => match err {
LoadError::Deserialization(err) => {
tracing::warn!(
error.message = %err,
error.cause_chain = ?err,
"Invalid session state, creating a new empty session."
);
Ok((Some(session_key), HashMap::new()))
}
LoadError::Other(err) => Err(e500(err)),
},
}
} else {
Ok((None, HashMap::new()))
}
}
fn set_session_cookie(
response: &mut ResponseHead,
session_key: SessionKey,
config: &CookieConfiguration,
) -> Result<(), anyhow::Error> {
let value: String = session_key.into();
let mut cookie = Cookie::new(config.name.clone(), value);
cookie.set_secure(config.secure);
cookie.set_http_only(config.http_only);
cookie.set_same_site(config.same_site);
cookie.set_path(config.path.clone());
if let Some(max_age) = config.max_age {
cookie.set_max_age(max_age);
}
if let Some(ref domain) = config.domain {
cookie.set_domain(domain.clone());
}
let mut jar = CookieJar::new();
match config.content_security {
CookieContentSecurity::Signed => jar.signed_mut(&config.key).add(cookie),
CookieContentSecurity::Private => jar.private_mut(&config.key).add(cookie),
}
// set cookie
let cookie = jar.delta().next().unwrap();
let val = HeaderValue::from_str(&cookie.encoded().to_string())
.context("Failed to attach a session cookie to the outgoing response")?;
response.headers_mut().append(SET_COOKIE, val);
Ok(())
}
fn delete_session_cookie(
response: &mut ResponseHead,
config: &CookieConfiguration,
) -> Result<(), anyhow::Error> {
let removal_cookie = Cookie::build(config.name.clone(), "")
.path(config.path.clone())
.secure(config.secure)
.http_only(config.http_only)
.same_site(config.same_site);
let mut removal_cookie = if let Some(ref domain) = config.domain {
removal_cookie.domain(domain)
} else {
removal_cookie
}
.finish();
removal_cookie.make_removal();
let val = HeaderValue::from_str(&removal_cookie.to_string())
.context("Failed to attach a session removal cookie to the outgoing response")?;
response.headers_mut().append(SET_COOKIE, val);
Ok(())
}

View File

@ -1,424 +0,0 @@
use std::{
cell::{Ref, RefCell},
collections::HashMap,
error::Error as StdError,
mem,
rc::Rc,
};
use actix_utils::future::{ready, Ready};
use actix_web::{
body::BoxBody,
dev::{Extensions, Payload, ServiceRequest, ServiceResponse},
error::Error,
FromRequest, HttpMessage, HttpRequest, HttpResponse, ResponseError,
};
use anyhow::Context;
use derive_more::derive::{Display, From};
use serde::{de::DeserializeOwned, Serialize};
/// The primary interface to access and modify session state.
///
/// [`Session`] is an [extractor](#impl-FromRequest)—you can specify it as an input type for your
/// request handlers and it will be automatically extracted from the incoming request.
///
/// ```
/// use actix_session::Session;
///
/// async fn index(session: Session) -> actix_web::Result<&'static str> {
/// // access session data
/// if let Some(count) = session.get::<i32>("counter")? {
/// session.insert("counter", count + 1)?;
/// } else {
/// session.insert("counter", 1)?;
/// }
///
/// // or use the shorthand
/// session.update_or("counter", 1, |count: i32| count + 1);
///
/// Ok("Welcome!")
/// }
/// # actix_web::web::to(index);
/// ```
///
/// You can also retrieve a [`Session`] object from an `HttpRequest` or a `ServiceRequest` using
/// [`SessionExt`].
///
/// [`SessionExt`]: crate::SessionExt
#[derive(Clone)]
pub struct Session(Rc<RefCell<SessionInner>>);
/// Status of a [`Session`].
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum SessionStatus {
/// Session state has been updated - the changes will have to be persisted to the backend.
Changed,
/// The session has been flagged for deletion - the session cookie will be removed from
/// the client and the session state will be deleted from the session store.
///
/// Most operations on the session after it has been marked for deletion will have no effect.
Purged,
/// The session has been flagged for renewal.
///
/// The session key will be regenerated and the time-to-live of the session state will be
/// extended.
Renewed,
/// The session state has not been modified since its creation/retrieval.
#[default]
Unchanged,
}
#[derive(Default)]
struct SessionInner {
state: HashMap<String, String>,
status: SessionStatus,
}
impl Session {
/// Get a `value` from the session.
///
/// It returns an error if it fails to deserialize as `T` the JSON value associated with `key`.
pub fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, SessionGetError> {
if let Some(val_str) = self.0.borrow().state.get(key) {
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 {
Ok(None)
}
}
/// Returns `true` if the session contains a value for the specified `key`.
pub fn contains_key(&self, key: &str) -> bool {
self.0.borrow().state.contains_key(key)
}
/// Get all raw key-value data from the session.
///
/// Note that values are JSON encoded.
pub fn entries(&self) -> Ref<'_, HashMap<String, String>> {
Ref::map(self.0.borrow(), |inner| &inner.state)
}
/// Returns session status.
pub fn status(&self) -> SessionStatus {
Ref::map(self.0.borrow(), |inner| &inner.status).clone()
}
/// Inserts a key-value pair into the session.
///
/// Any serializable value can be used and will be encoded as JSON in session data, hence why
/// only a reference to the value is taken.
///
/// # Errors
///
/// Returns an error if JSON serialization of `value` fails.
pub fn insert<T: Serialize>(
&self,
key: impl Into<String>,
value: T,
) -> Result<(), SessionInsertError> {
let mut inner = self.0.borrow_mut();
if inner.status != SessionStatus::Purged {
if inner.status != SessionStatus::Renewed {
inner.status = SessionStatus::Changed;
}
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}` key",
std::any::type_name::<T>(),
)
})
.map_err(SessionInsertError)?;
inner.state.insert(key, val);
}
Ok(())
}
/// Updates a key-value pair into the session.
///
/// If the key exists then update it to the new value and place it back in. If the key does not
/// exist it will not be updated.
///
/// Any serializable value can be used and will be encoded as JSON in the session data, hence
/// why only a reference to the value is taken.
///
/// # Errors
///
/// Returns an error if JSON serialization of the value fails.
pub fn update<T: Serialize + DeserializeOwned, F>(
&self,
key: impl Into<String>,
updater: F,
) -> Result<(), SessionUpdateError>
where
F: FnOnce(T) -> T,
{
let mut inner = self.0.borrow_mut();
let key_str = key.into();
if let Some(val_str) = inner.state.get(&key_str) {
let value = serde_json::from_str(val_str)
.with_context(|| {
format!(
"Failed to deserialize the JSON-encoded session data attached to key \
`{key_str}` as a `{}` type",
std::any::type_name::<T>()
)
})
.map_err(SessionUpdateError)?;
let val = serde_json::to_string(&updater(value))
.with_context(|| {
format!(
"Failed to serialize the provided `{}` type instance as JSON in order to \
attach as session data to the `{key_str}` key",
std::any::type_name::<T>(),
)
})
.map_err(SessionUpdateError)?;
inner.state.insert(key_str, val);
}
Ok(())
}
/// Updates a key-value pair into the session, or inserts a default value.
///
/// If the key exists then update it to the new value and place it back in. If the key does not
/// exist the default value will be inserted instead.
///
/// Any serializable value can be used and will be encoded as JSON in session data, hence why
/// only a reference to the value is taken.
///
/// # Errors
///
/// Returns error if JSON serialization of a value fails.
pub fn update_or<T: Serialize + DeserializeOwned, F>(
&self,
key: &str,
default_value: T,
updater: F,
) -> Result<(), SessionUpdateError>
where
F: FnOnce(T) -> T,
{
if self.contains_key(key) {
self.update(key, updater)
} else {
self.insert(key, default_value)
.map_err(|err| SessionUpdateError(err.into()))
}
}
/// Remove value from the session.
///
/// If present, the JSON encoded value is returned.
pub fn remove(&self, key: &str) -> Option<String> {
let mut inner = self.0.borrow_mut();
if inner.status != SessionStatus::Purged {
if inner.status != SessionStatus::Renewed {
inner.status = SessionStatus::Changed;
}
return inner.state.remove(key);
}
None
}
/// Remove value from the session and deserialize.
///
/// Returns `None` if key was not present in session. Returns `T` if deserialization succeeds,
/// otherwise returns un-deserialized JSON string.
pub fn remove_as<T: DeserializeOwned>(&self, key: &str) -> Option<Result<T, String>> {
self.remove(key)
.map(|val_str| match serde_json::from_str(&val_str) {
Ok(val) => Ok(val),
Err(_err) => {
tracing::debug!(
"Removed value (key: {}) could not be deserialized as {}",
key,
std::any::type_name::<T>()
);
Err(val_str)
}
})
}
/// Clear the session.
pub fn clear(&self) {
let mut inner = self.0.borrow_mut();
if inner.status != SessionStatus::Purged {
if inner.status != SessionStatus::Renewed {
inner.status = SessionStatus::Changed;
}
inner.state.clear()
}
}
/// Removes session both client and server side.
pub fn purge(&self) {
let mut inner = self.0.borrow_mut();
inner.status = SessionStatus::Purged;
inner.state.clear();
}
/// Renews the session key, assigning existing session state to new key.
pub fn renew(&self) {
let mut inner = self.0.borrow_mut();
if inner.status != SessionStatus::Purged {
inner.status = SessionStatus::Renewed;
}
}
/// Adds the given key-value pairs to the session on the request.
///
/// Values that match keys already existing on the session will be overwritten. Values should
/// already be JSON serialized.
#[allow(clippy::needless_pass_by_ref_mut)]
pub(crate) fn set_session(
req: &mut ServiceRequest,
data: impl IntoIterator<Item = (String, String)>,
) {
let session = Session::get_session(&mut req.extensions_mut());
let mut inner = session.0.borrow_mut();
inner.state.extend(data);
}
/// Returns session status and iterator of key-value pairs of changes.
///
/// This is a destructive operation - the session state is removed from the request extensions
/// typemap, leaving behind a new empty map. It should only be used when the session is being
/// finalised (i.e. in `SessionMiddleware`).
#[allow(clippy::needless_pass_by_ref_mut)]
pub(crate) fn get_changes<B>(
res: &mut ServiceResponse<B>,
) -> (SessionStatus, HashMap<String, String>) {
if let Some(s_impl) = res
.request()
.extensions()
.get::<Rc<RefCell<SessionInner>>>()
{
let state = mem::take(&mut s_impl.borrow_mut().state);
(s_impl.borrow().status.clone(), state)
} else {
(SessionStatus::Unchanged, HashMap::new())
}
}
pub(crate) fn get_session(extensions: &mut Extensions) -> Session {
if let Some(s_impl) = extensions.get::<Rc<RefCell<SessionInner>>>() {
return Session(Rc::clone(s_impl));
}
let inner = Rc::new(RefCell::new(SessionInner::default()));
extensions.insert(inner.clone());
Session(inner)
}
}
/// Extractor implementation for [`Session`]s.
///
/// # Examples
/// ```
/// # use actix_web::*;
/// use actix_session::Session;
///
/// #[get("/")]
/// async fn index(session: Session) -> Result<impl Responder> {
/// // access session data
/// if let Some(count) = session.get::<i32>("counter")? {
/// session.insert("counter", count + 1)?;
/// } else {
/// session.insert("counter", 1)?;
/// }
///
/// let count = session.get::<i32>("counter")?.unwrap();
/// Ok(format!("Counter: {}", count))
/// }
/// ```
impl FromRequest for Session {
type Error = Error;
type Future = Ready<Result<Session, Error>>;
#[inline]
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
ready(Ok(Session::get_session(&mut req.extensions_mut())))
}
}
/// Error returned by [`Session::get`].
#[derive(Debug, Display, From)]
#[display("{_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("{_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())
}
}
/// Error returned by [`Session::update`].
#[derive(Debug, Display, From)]
#[display("{_0}")]
pub struct SessionUpdateError(anyhow::Error);
impl StdError for SessionUpdateError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
Some(self.0.as_ref())
}
}
impl ResponseError for SessionUpdateError {
fn error_response(&self) -> HttpResponse<BoxBody> {
HttpResponse::new(self.status_code())
}
}

View File

@ -1,38 +0,0 @@
use actix_web::{
dev::{ServiceRequest, ServiceResponse},
guard::GuardContext,
HttpMessage, HttpRequest,
};
use crate::Session;
/// Extract a [`Session`] object from various `actix-web` types (e.g. `HttpRequest`,
/// `ServiceRequest`, `ServiceResponse`).
pub trait SessionExt {
/// Extract a [`Session`] object.
fn get_session(&self) -> Session;
}
impl SessionExt for HttpRequest {
fn get_session(&self) -> Session {
Session::get_session(&mut self.extensions_mut())
}
}
impl SessionExt for ServiceRequest {
fn get_session(&self) -> Session {
Session::get_session(&mut self.extensions_mut())
}
}
impl SessionExt for ServiceResponse {
fn get_session(&self) -> Session {
self.request().get_session()
}
}
impl SessionExt for GuardContext<'_> {
fn get_session(&self) -> Session {
Session::get_session(&mut self.req_data_mut())
}
}

View File

@ -1,117 +0,0 @@
use actix_web::cookie::time::Duration;
use anyhow::Error;
use super::SessionKey;
use crate::storage::{
interface::{LoadError, SaveError, SessionState, UpdateError},
SessionStore,
};
/// Use the session key, stored in the session cookie, as storage backend for the session state.
///
/// ```no_run
/// use actix_web::{cookie::Key, web, App, HttpServer, HttpResponse, Error};
/// use actix_session::{SessionMiddleware, storage::CookieSessionStore};
///
/// // The secret key would usually be read from a configuration file/environment variables.
/// fn get_secret_key() -> Key {
/// # todo!()
/// // [...]
/// }
///
/// #[actix_web::main]
/// async fn main() -> std::io::Result<()> {
/// let secret_key = get_secret_key();
/// HttpServer::new(move ||
/// App::new()
/// .wrap(SessionMiddleware::new(CookieSessionStore::default(), secret_key.clone()))
/// .default_service(web::to(|| HttpResponse::Ok())))
/// .bind(("127.0.0.1", 8080))?
/// .run()
/// .await
/// }
/// ```
///
/// # Limitations
/// Cookies are subject to size limits so we require session keys to be shorter than 4096 bytes.
/// This translates into a limit on the maximum size of the session state when using cookies as
/// storage backend.
///
/// The session cookie can always be inspected by end users via the developer tools exposed by their
/// browsers. We strongly recommend setting the policy to [`CookieContentSecurity::Private`] when
/// using cookies as storage backend.
///
/// There is no way to invalidate a session before its natural expiry when using cookies as the
/// storage backend.
///
/// [`CookieContentSecurity::Private`]: crate::config::CookieContentSecurity::Private
#[derive(Default)]
#[non_exhaustive]
pub struct CookieSessionStore;
impl SessionStore for CookieSessionStore {
async fn load(&self, session_key: &SessionKey) -> Result<Option<SessionState>, LoadError> {
serde_json::from_str(session_key.as_ref())
.map(Some)
.map_err(anyhow::Error::new)
.map_err(LoadError::Deserialization)
}
async fn save(
&self,
session_state: SessionState,
_ttl: &Duration,
) -> Result<SessionKey, SaveError> {
let session_key = serde_json::to_string(&session_state)
.map_err(anyhow::Error::new)
.map_err(SaveError::Serialization)?;
session_key
.try_into()
.map_err(Into::into)
.map_err(SaveError::Other)
}
async fn update(
&self,
_session_key: SessionKey,
session_state: SessionState,
ttl: &Duration,
) -> Result<SessionKey, UpdateError> {
self.save(session_state, ttl)
.await
.map_err(|err| match err {
SaveError::Serialization(err) => UpdateError::Serialization(err),
SaveError::Other(err) => UpdateError::Other(err),
})
}
async fn update_ttl(&self, _session_key: &SessionKey, _ttl: &Duration) -> Result<(), Error> {
Ok(())
}
async fn delete(&self, _session_key: &SessionKey) -> Result<(), anyhow::Error> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{storage::utils::generate_session_key, test_helpers::acceptance_test_suite};
#[actix_web::test]
async fn test_session_workflow() {
acceptance_test_suite(CookieSessionStore::default, false).await;
}
#[actix_web::test]
async fn loading_a_random_session_key_returns_deserialization_error() {
let store = CookieSessionStore::default();
let session_key = generate_session_key();
assert!(matches!(
store.load(&session_key).await.unwrap_err(),
LoadError::Deserialization(_),
));
}
}

View File

@ -1,113 +0,0 @@
use std::{collections::HashMap, future::Future};
use actix_web::cookie::time::Duration;
use derive_more::derive::Display;
use super::SessionKey;
pub(crate) type SessionState = HashMap<String, String>;
/// The interface to retrieve and save the current session data from/to the chosen storage backend.
///
/// You can provide your own custom session store backend by implementing this trait.
pub trait SessionStore {
/// Loads the session state associated to a session key.
fn load(
&self,
session_key: &SessionKey,
) -> impl Future<Output = Result<Option<SessionState>, LoadError>>;
/// Persist the session state for a newly created session.
///
/// Returns the corresponding session key.
fn save(
&self,
session_state: SessionState,
ttl: &Duration,
) -> impl Future<Output = Result<SessionKey, SaveError>>;
/// Updates the session state associated to a pre-existing session key.
fn update(
&self,
session_key: SessionKey,
session_state: SessionState,
ttl: &Duration,
) -> impl Future<Output = Result<SessionKey, UpdateError>>;
/// Updates the TTL of the session state associated to a pre-existing session key.
fn update_ttl(
&self,
session_key: &SessionKey,
ttl: &Duration,
) -> impl Future<Output = Result<(), anyhow::Error>>;
/// Deletes a session from the store.
fn delete(&self, session_key: &SessionKey) -> impl Future<Output = Result<(), anyhow::Error>>;
}
// We cannot derive the `Error` implementation using `derive_more` for our custom errors:
// `derive_more`'s `#[error(source)]` attribute requires the source implement the `Error` trait,
// while it's actually enough for it to be able to produce a reference to a dyn Error.
/// Possible failures modes for [`SessionStore::load`].
#[derive(Debug, Display)]
pub enum LoadError {
/// Failed to deserialize session state.
#[display("Failed to deserialize session state")]
Deserialization(anyhow::Error),
/// Something went wrong when retrieving the session state.
#[display("Something went wrong when retrieving the session state")]
Other(anyhow::Error),
}
impl std::error::Error for LoadError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Deserialization(err) => Some(err.as_ref()),
Self::Other(err) => Some(err.as_ref()),
}
}
}
/// Possible failures modes for [`SessionStore::save`].
#[derive(Debug, Display)]
pub enum SaveError {
/// Failed to serialize session state.
#[display("Failed to serialize session state")]
Serialization(anyhow::Error),
/// Something went wrong when persisting the session state.
#[display("Something went wrong when persisting the session state")]
Other(anyhow::Error),
}
impl std::error::Error for SaveError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Serialization(err) => Some(err.as_ref()),
Self::Other(err) => Some(err.as_ref()),
}
}
}
#[derive(Debug, Display)]
/// Possible failures modes for [`SessionStore::update`].
pub enum UpdateError {
/// Failed to serialize session state.
#[display("Failed to serialize session state")]
Serialization(anyhow::Error),
/// Something went wrong when updating the session state.
#[display("Something went wrong when updating the session state.")]
Other(anyhow::Error),
}
impl std::error::Error for UpdateError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Serialization(err) => Some(err.as_ref()),
Self::Other(err) => Some(err.as_ref()),
}
}
}

View File

@ -1,19 +0,0 @@
//! Pluggable storage backends for session state.
#[cfg(feature = "cookie-session")]
mod cookie;
mod interface;
#[cfg(feature = "redis-session")]
mod redis_rs;
mod session_key;
mod utils;
#[cfg(feature = "cookie-session")]
pub use self::cookie::CookieSessionStore;
#[cfg(feature = "redis-session")]
pub use self::redis_rs::{RedisSessionStore, RedisSessionStoreBuilder};
pub use self::{
interface::{LoadError, SaveError, SessionStore, UpdateError},
session_key::SessionKey,
utils::generate_session_key,
};

View File

@ -1,310 +0,0 @@
use actix::Addr;
use actix_redis::{resp_array, Command, RedisActor, RespValue};
use actix_web::cookie::time::Duration;
use anyhow::Error;
use super::SessionKey;
use crate::storage::{
interface::{LoadError, SaveError, SessionState, UpdateError},
utils::generate_session_key,
SessionStore,
};
/// Use Redis as session storage backend.
///
/// ```no_run
/// use actix_web::{web, App, HttpServer, HttpResponse, Error};
/// use actix_session::{SessionMiddleware, storage::RedisActorSessionStore};
/// use actix_web::cookie::Key;
///
/// // The secret key would usually be read from a configuration file/environment variables.
/// fn get_secret_key() -> Key {
/// # todo!()
/// // [...]
/// }
///
/// #[actix_web::main]
/// async fn main() -> std::io::Result<()> {
/// let secret_key = get_secret_key();
/// let redis_connection_string = "127.0.0.1:6379";
/// HttpServer::new(move ||
/// App::new()
/// .wrap(
/// SessionMiddleware::new(
/// RedisActorSessionStore::new(redis_connection_string),
/// secret_key.clone()
/// )
/// )
/// .default_service(web::to(|| HttpResponse::Ok())))
/// .bind(("127.0.0.1", 8080))?
/// .run()
/// .await
/// }
/// ```
///
/// # Implementation notes
///
/// `RedisActorSessionStore` leverages `actix-redis`'s `RedisActor` implementation - each thread
/// worker gets its own connection to Redis.
///
/// ## Limitations
///
/// `RedisActorSessionStore` does not currently support establishing authenticated connections to
/// Redis. Use [`RedisSessionStore`] if you need TLS support.
///
/// [`RedisSessionStore`]: crate::storage::RedisSessionStore
pub struct RedisActorSessionStore {
configuration: CacheConfiguration,
addr: Addr<RedisActor>,
}
impl RedisActorSessionStore {
/// A fluent API to configure [`RedisActorSessionStore`].
///
/// It takes as input the only required input to create a new instance of
/// [`RedisActorSessionStore`]—a connection string for Redis.
pub fn builder<S: Into<String>>(connection_string: S) -> RedisActorSessionStoreBuilder {
RedisActorSessionStoreBuilder {
configuration: CacheConfiguration::default(),
connection_string: connection_string.into(),
}
}
/// Create a new instance of [`RedisActorSessionStore`] using the default configuration.
/// It takes as input the only required input to create a new instance of [`RedisActorSessionStore`] - a
/// connection string for Redis.
pub fn new<S: Into<String>>(connection_string: S) -> RedisActorSessionStore {
Self::builder(connection_string).build()
}
}
struct CacheConfiguration {
cache_keygen: Box<dyn Fn(&str) -> String>,
}
impl Default for CacheConfiguration {
fn default() -> Self {
Self {
cache_keygen: Box::new(str::to_owned),
}
}
}
/// A fluent builder to construct a [`RedisActorSessionStore`] instance with custom configuration
/// parameters.
#[must_use]
pub struct RedisActorSessionStoreBuilder {
connection_string: String,
configuration: CacheConfiguration,
}
impl RedisActorSessionStoreBuilder {
/// Set a custom cache key generation strategy, expecting a session key as input.
pub fn cache_keygen<F>(mut self, keygen: F) -> Self
where
F: Fn(&str) -> String + 'static,
{
self.configuration.cache_keygen = Box::new(keygen);
self
}
/// Finalise the builder and return a [`RedisActorSessionStore`] instance.
#[must_use]
pub fn build(self) -> RedisActorSessionStore {
RedisActorSessionStore {
configuration: self.configuration,
addr: RedisActor::start(self.connection_string),
}
}
}
impl SessionStore for RedisActorSessionStore {
async fn load(&self, session_key: &SessionKey) -> Result<Option<SessionState>, LoadError> {
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
let val = self
.addr
.send(Command(resp_array!["GET", cache_key]))
.await
.map_err(Into::into)
.map_err(LoadError::Other)?
.map_err(Into::into)
.map_err(LoadError::Other)?;
match val {
RespValue::Error(err) => Err(LoadError::Other(anyhow::anyhow!(err))),
RespValue::SimpleString(s) => Ok(serde_json::from_str(&s)
.map_err(Into::into)
.map_err(LoadError::Deserialization)?),
RespValue::BulkString(s) => Ok(serde_json::from_slice(&s)
.map_err(Into::into)
.map_err(LoadError::Deserialization)?),
_ => Ok(None),
}
}
async fn save(
&self,
session_state: SessionState,
ttl: &Duration,
) -> Result<SessionKey, SaveError> {
let body = serde_json::to_string(&session_state)
.map_err(Into::into)
.map_err(SaveError::Serialization)?;
let session_key = generate_session_key();
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
let cmd = Command(resp_array![
"SET",
cache_key,
body,
"NX", // NX: only set the key if it does not already exist
"EX", // EX: set expiry
format!("{}", ttl.whole_seconds())
]);
let result = self
.addr
.send(cmd)
.await
.map_err(Into::into)
.map_err(SaveError::Other)?
.map_err(Into::into)
.map_err(SaveError::Other)?;
match result {
RespValue::SimpleString(_) => Ok(session_key),
RespValue::Nil => Err(SaveError::Other(anyhow::anyhow!(
"Failed to save session state. A record with the same key already existed in Redis"
))),
err => Err(SaveError::Other(anyhow::anyhow!(
"Failed to save session state. {:?}",
err
))),
}
}
async fn update(
&self,
session_key: SessionKey,
session_state: SessionState,
ttl: &Duration,
) -> Result<SessionKey, UpdateError> {
let body = serde_json::to_string(&session_state)
.map_err(Into::into)
.map_err(UpdateError::Serialization)?;
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
let cmd = Command(resp_array![
"SET",
cache_key,
body,
"XX", // XX: Only set the key if it already exist.
"EX", // EX: set expiry
format!("{}", ttl.whole_seconds())
]);
let result = self
.addr
.send(cmd)
.await
.map_err(Into::into)
.map_err(UpdateError::Other)?
.map_err(Into::into)
.map_err(UpdateError::Other)?;
match result {
RespValue::Nil => {
// The SET operation was not performed because the XX condition was not verified.
// This can happen if the session state expired between the load operation and the
// update operation. Unlucky, to say the least. We fall back to the `save` routine
// to ensure that the new key is unique.
self.save(session_state, ttl)
.await
.map_err(|err| match err {
SaveError::Serialization(err) => UpdateError::Serialization(err),
SaveError::Other(err) => UpdateError::Other(err),
})
}
RespValue::SimpleString(_) => Ok(session_key),
val => Err(UpdateError::Other(anyhow::anyhow!(
"Failed to update session state. {:?}",
val
))),
}
}
async fn update_ttl(&self, session_key: &SessionKey, ttl: &Duration) -> Result<(), Error> {
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
let cmd = Command(resp_array![
"EXPIRE",
cache_key,
ttl.whole_seconds().to_string()
]);
match self.addr.send(cmd).await? {
Ok(RespValue::Integer(_)) => Ok(()),
val => Err(anyhow::anyhow!(
"Failed to update the session state TTL: {:?}",
val
)),
}
}
async fn delete(&self, session_key: &SessionKey) -> Result<(), anyhow::Error> {
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
let res = self
.addr
.send(Command(resp_array!["DEL", cache_key]))
.await?;
match res {
// Redis returns the number of deleted records
Ok(RespValue::Integer(_)) => Ok(()),
val => Err(anyhow::anyhow!(
"Failed to remove session from cache. {:?}",
val
)),
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::test_helpers::acceptance_test_suite;
fn redis_actor_store() -> RedisActorSessionStore {
RedisActorSessionStore::new("127.0.0.1:6379")
}
#[actix_web::test]
async fn test_session_workflow() {
acceptance_test_suite(redis_actor_store, true).await;
}
#[actix_web::test]
async fn loading_a_missing_session_returns_none() {
let store = redis_actor_store();
let session_key = generate_session_key();
assert!(store.load(&session_key).await.unwrap().is_none());
}
#[actix_web::test]
async fn updating_of_an_expired_state_is_handled_gracefully() {
let store = redis_actor_store();
let session_key = generate_session_key();
let initial_session_key = session_key.as_ref().to_owned();
let updated_session_key = store
.update(session_key, HashMap::new(), &Duration::seconds(1))
.await
.unwrap();
assert_ne!(initial_session_key, updated_session_key.as_ref());
}
}

View File

@ -1,476 +0,0 @@
use std::sync::Arc;
use actix_web::cookie::time::Duration;
use anyhow::Error;
use redis::{aio::ConnectionManager, AsyncCommands, Client, Cmd, FromRedisValue, Value};
use super::SessionKey;
use crate::storage::{
interface::{LoadError, SaveError, SessionState, UpdateError},
utils::generate_session_key,
SessionStore,
};
/// Use Redis as session storage backend.
///
/// ```no_run
/// use actix_web::{web, App, HttpServer, HttpResponse, Error};
/// use actix_session::{SessionMiddleware, storage::RedisSessionStore};
/// use actix_web::cookie::Key;
///
/// // The secret key would usually be read from a configuration file/environment variables.
/// fn get_secret_key() -> Key {
/// # todo!()
/// // [...]
/// }
///
/// #[actix_web::main]
/// async fn main() -> std::io::Result<()> {
/// let secret_key = get_secret_key();
/// let redis_connection_string = "redis://127.0.0.1:6379";
/// let store = RedisSessionStore::new(redis_connection_string).await.unwrap();
///
/// HttpServer::new(move ||
/// App::new()
/// .wrap(SessionMiddleware::new(
/// store.clone(),
/// secret_key.clone()
/// ))
/// .default_service(web::to(|| HttpResponse::Ok())))
/// .bind(("127.0.0.1", 8080))?
/// .run()
/// .await
/// }
/// ```
///
/// # TLS support
/// Add the `redis-session-native-tls` or `redis-session-rustls` feature flag to enable TLS support. You can then establish a TLS
/// connection to Redis using the `rediss://` URL scheme:
///
/// ```no_run
/// use actix_session::{storage::RedisSessionStore};
///
/// # actix_web::rt::System::new().block_on(async {
/// let redis_connection_string = "rediss://127.0.0.1:6379";
/// let store = RedisSessionStore::new(redis_connection_string).await.unwrap();
/// # })
/// ```
///
/// # Pooled Redis Connections
///
/// When the `redis-pool` crate feature is enabled, a pre-existing pool from [`deadpool_redis`] can
/// be provided.
///
/// ```no_run
/// use actix_session::storage::RedisSessionStore;
/// use deadpool_redis::{Config, Runtime};
///
/// let redis_cfg = Config::from_url("redis://127.0.0.1:6379");
/// let redis_pool = redis_cfg.create_pool(Some(Runtime::Tokio1)).unwrap();
///
/// let store = RedisSessionStore::new_pooled(redis_pool);
/// ```
///
/// # Implementation notes
///
/// `RedisSessionStore` leverages the [`redis`] crate as the underlying Redis client.
#[derive(Clone)]
pub struct RedisSessionStore {
configuration: CacheConfiguration,
client: RedisSessionConn,
}
#[derive(Clone)]
enum RedisSessionConn {
/// Single connection.
Single(ConnectionManager),
/// Connection pool.
#[cfg(feature = "redis-pool")]
Pool(deadpool_redis::Pool),
}
#[derive(Clone)]
struct CacheConfiguration {
cache_keygen: Arc<dyn Fn(&str) -> String + Send + Sync>,
}
impl Default for CacheConfiguration {
fn default() -> Self {
Self {
cache_keygen: Arc::new(str::to_owned),
}
}
}
impl RedisSessionStore {
/// Returns a fluent API builder to configure [`RedisSessionStore`].
///
/// It takes as input the only required input to create a new instance of [`RedisSessionStore`]
/// - a connection string for Redis.
pub fn builder(connection_string: impl Into<String>) -> RedisSessionStoreBuilder {
RedisSessionStoreBuilder {
configuration: CacheConfiguration::default(),
conn_builder: RedisSessionConnBuilder::Single(connection_string.into()),
}
}
/// Returns a fluent API builder to configure [`RedisSessionStore`].
///
/// It takes as input the only required input to create a new instance of [`RedisSessionStore`]
/// - a pool object for Redis.
#[cfg(feature = "redis-pool")]
pub fn builder_pooled(pool: impl Into<deadpool_redis::Pool>) -> RedisSessionStoreBuilder {
RedisSessionStoreBuilder {
configuration: CacheConfiguration::default(),
conn_builder: RedisSessionConnBuilder::Pool(pool.into()),
}
}
/// Creates a new instance of [`RedisSessionStore`] using the default configuration.
///
/// It takes as input the only required input to create a new instance of [`RedisSessionStore`]
/// - a connection string for Redis.
pub async fn new(connection_string: impl Into<String>) -> Result<RedisSessionStore, Error> {
Self::builder(connection_string).build().await
}
/// Creates a new instance of [`RedisSessionStore`] using the default configuration.
///
/// It takes as input the only required input to create a new instance of [`RedisSessionStore`]
/// - a pool object for Redis.
#[cfg(feature = "redis-pool")]
pub async fn new_pooled(
pool: impl Into<deadpool_redis::Pool>,
) -> anyhow::Result<RedisSessionStore> {
Self::builder_pooled(pool).build().await
}
}
/// A fluent builder to construct a [`RedisSessionStore`] instance with custom configuration
/// parameters.
#[must_use]
pub struct RedisSessionStoreBuilder {
configuration: CacheConfiguration,
conn_builder: RedisSessionConnBuilder,
}
enum RedisSessionConnBuilder {
/// Single connection string.
Single(String),
/// Pre-built connection pool.
#[cfg(feature = "redis-pool")]
Pool(deadpool_redis::Pool),
}
impl RedisSessionConnBuilder {
async fn into_client(self) -> anyhow::Result<RedisSessionConn> {
Ok(match self {
RedisSessionConnBuilder::Single(conn_string) => {
RedisSessionConn::Single(ConnectionManager::new(Client::open(conn_string)?).await?)
}
#[cfg(feature = "redis-pool")]
RedisSessionConnBuilder::Pool(pool) => RedisSessionConn::Pool(pool),
})
}
}
impl RedisSessionStoreBuilder {
/// Set a custom cache key generation strategy, expecting a session key as input.
pub fn cache_keygen<F>(mut self, keygen: F) -> Self
where
F: Fn(&str) -> String + 'static + Send + Sync,
{
self.configuration.cache_keygen = Arc::new(keygen);
self
}
/// Finalises builder and returns a [`RedisSessionStore`] instance.
pub async fn build(self) -> anyhow::Result<RedisSessionStore> {
let client = self.conn_builder.into_client().await?;
Ok(RedisSessionStore {
configuration: self.configuration,
client,
})
}
}
impl SessionStore for RedisSessionStore {
async fn load(&self, session_key: &SessionKey) -> Result<Option<SessionState>, LoadError> {
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
let value: Option<String> = self
.execute_command(redis::cmd("GET").arg(&[&cache_key]))
.await
.map_err(LoadError::Other)?;
match value {
None => Ok(None),
Some(value) => Ok(serde_json::from_str(&value)
.map_err(Into::into)
.map_err(LoadError::Deserialization)?),
}
}
async fn save(
&self,
session_state: SessionState,
ttl: &Duration,
) -> Result<SessionKey, SaveError> {
let body = serde_json::to_string(&session_state)
.map_err(Into::into)
.map_err(SaveError::Serialization)?;
let session_key = generate_session_key();
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
self.execute_command::<()>(
redis::cmd("SET")
.arg(&[
&cache_key, // key
&body, // value
"NX", // only set the key if it does not already exist
"EX", // set expiry / TTL
])
.arg(
ttl.whole_seconds(), // EXpiry in seconds
),
)
.await
.map_err(SaveError::Other)?;
Ok(session_key)
}
async fn update(
&self,
session_key: SessionKey,
session_state: SessionState,
ttl: &Duration,
) -> Result<SessionKey, UpdateError> {
let body = serde_json::to_string(&session_state)
.map_err(Into::into)
.map_err(UpdateError::Serialization)?;
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
let v: Value = self
.execute_command(redis::cmd("SET").arg(&[
&cache_key,
&body,
"XX", // XX: Only set the key if it already exist.
"EX", // EX: set expiry
&format!("{}", ttl.whole_seconds()),
]))
.await
.map_err(UpdateError::Other)?;
match v {
Value::Nil => {
// The SET operation was not performed because the XX condition was not verified.
// This can happen if the session state expired between the load operation and the
// update operation. Unlucky, to say the least. We fall back to the `save` routine
// to ensure that the new key is unique.
self.save(session_state, ttl)
.await
.map_err(|err| match err {
SaveError::Serialization(err) => UpdateError::Serialization(err),
SaveError::Other(err) => UpdateError::Other(err),
})
}
Value::Int(_) | Value::Okay | Value::SimpleString(_) => Ok(session_key),
val => Err(UpdateError::Other(anyhow::anyhow!(
"Failed to update session state. {:?}",
val
))),
}
}
async fn update_ttl(&self, session_key: &SessionKey, ttl: &Duration) -> anyhow::Result<()> {
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
match self.client {
RedisSessionConn::Single(ref conn) => {
conn.clone()
.expire::<_, ()>(&cache_key, ttl.whole_seconds())
.await?;
}
#[cfg(feature = "redis-pool")]
RedisSessionConn::Pool(ref pool) => {
pool.get()
.await?
.expire::<_, ()>(&cache_key, ttl.whole_seconds())
.await?;
}
}
Ok(())
}
async fn delete(&self, session_key: &SessionKey) -> Result<(), Error> {
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
self.execute_command::<()>(redis::cmd("DEL").arg(&[&cache_key]))
.await
.map_err(UpdateError::Other)?;
Ok(())
}
}
impl RedisSessionStore {
/// Execute Redis command and retry once in certain cases.
///
/// `ConnectionManager` automatically reconnects when it encounters an error talking to Redis.
/// The request that bumped into the error, though, fails.
///
/// This is generally OK, but there is an unpleasant edge case: Redis client timeouts. The
/// server is configured to drop connections who have been active longer than a pre-determined
/// threshold. `redis-rs` does not proactively detect that the connection has been dropped - you
/// only find out when you try to use it.
///
/// This helper method catches this case (`.is_connection_dropped`) to execute a retry. The
/// retry will be executed on a fresh connection, therefore it is likely to succeed (or fail for
/// a different more meaningful reason).
#[allow(clippy::needless_pass_by_ref_mut)]
async fn execute_command<T: FromRedisValue>(&self, cmd: &mut Cmd) -> anyhow::Result<T> {
let mut can_retry = true;
match self.client {
RedisSessionConn::Single(ref conn) => {
let mut conn = conn.clone();
loop {
match cmd.query_async(&mut conn).await {
Ok(value) => return Ok(value),
Err(err) => {
if can_retry && err.is_connection_dropped() {
tracing::debug!(
"Connection dropped while trying to talk to Redis. Retrying."
);
// Retry at most once
can_retry = false;
continue;
} else {
return Err(err.into());
}
}
}
}
}
#[cfg(feature = "redis-pool")]
RedisSessionConn::Pool(ref pool) => {
let mut conn = pool.get().await?;
loop {
match cmd.query_async(&mut conn).await {
Ok(value) => return Ok(value),
Err(err) => {
if can_retry && err.is_connection_dropped() {
tracing::debug!(
"Connection dropped while trying to talk to Redis. Retrying."
);
// Retry at most once
can_retry = false;
continue;
} else {
return Err(err.into());
}
}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use actix_web::cookie::time;
#[cfg(not(feature = "redis-session"))]
use deadpool_redis::{Config, Runtime};
use super::*;
use crate::test_helpers::acceptance_test_suite;
async fn redis_store() -> RedisSessionStore {
#[cfg(feature = "redis-session")]
{
RedisSessionStore::new("redis://127.0.0.1:6379")
.await
.unwrap()
}
#[cfg(not(feature = "redis-session"))]
{
let redis_pool = Config::from_url("redis://127.0.0.1:6379")
.create_pool(Some(Runtime::Tokio1))
.unwrap();
RedisSessionStore::new(redis_pool.clone())
}
}
#[actix_web::test]
async fn test_session_workflow() {
let redis_store = redis_store().await;
acceptance_test_suite(move || redis_store.clone(), true).await;
}
#[actix_web::test]
async fn loading_a_missing_session_returns_none() {
let store = redis_store().await;
let session_key = generate_session_key();
assert!(store.load(&session_key).await.unwrap().is_none());
}
#[actix_web::test]
async fn loading_an_invalid_session_state_returns_deserialization_error() {
let store = redis_store().await;
let session_key = generate_session_key();
match store.client {
RedisSessionConn::Single(ref conn) => conn
.clone()
.set::<_, _, ()>(session_key.as_ref(), "random-thing-which-is-not-json")
.await
.unwrap(),
#[cfg(feature = "redis-pool")]
RedisSessionConn::Pool(ref pool) => {
pool.get()
.await
.unwrap()
.set::<_, _, ()>(session_key.as_ref(), "random-thing-which-is-not-json")
.await
.unwrap();
}
}
assert!(matches!(
store.load(&session_key).await.unwrap_err(),
LoadError::Deserialization(_),
));
}
#[actix_web::test]
async fn updating_of_an_expired_state_is_handled_gracefully() {
let store = redis_store().await;
let session_key = generate_session_key();
let initial_session_key = session_key.as_ref().to_owned();
let updated_session_key = store
.update(session_key, HashMap::new(), &time::Duration::seconds(1))
.await
.unwrap();
assert_ne!(initial_session_key, updated_session_key.as_ref());
}
}

View File

@ -1,55 +0,0 @@
use derive_more::derive::{Display, From};
/// A session key, the string stored in a client-side cookie to associate a user with its session
/// state on the backend.
///
/// # Validation
/// Session keys are stored as cookies, therefore they cannot be arbitrary long. Session keys are
/// required to be smaller than 4064 bytes.
///
/// ```
/// use actix_session::storage::SessionKey;
///
/// let key: String = std::iter::repeat('a').take(4065).collect();
/// let session_key: Result<SessionKey, _> = key.try_into();
/// assert!(session_key.is_err());
/// ```
#[derive(Debug, PartialEq, Eq)]
pub struct SessionKey(String);
impl TryFrom<String> for SessionKey {
type Error = InvalidSessionKeyError;
fn try_from(val: String) -> Result<Self, Self::Error> {
if val.len() > 4064 {
return Err(anyhow::anyhow!(
"The session key is bigger than 4064 bytes, the upper limit on cookie content."
)
.into());
}
Ok(SessionKey(val))
}
}
impl AsRef<str> for SessionKey {
fn as_ref(&self) -> &str {
&self.0
}
}
impl From<SessionKey> for String {
fn from(key: SessionKey) -> Self {
key.0
}
}
#[derive(Debug, Display, From)]
#[display("The provided string is not a valid session key")]
pub struct InvalidSessionKeyError(anyhow::Error);
impl std::error::Error for InvalidSessionKeyError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(self.0.as_ref())
}
}

View File

@ -1,13 +0,0 @@
use rand::distr::{Alphanumeric, SampleString as _};
use crate::storage::SessionKey;
/// Session key generation routine that follows [OWASP recommendations].
///
/// [OWASP recommendations]: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#session-id-entropy
pub fn generate_session_key() -> SessionKey {
Alphanumeric
.sample_string(&mut rand::rng(), 64)
.try_into()
.expect("generated string should be within size range for a session key")
}

View File

@ -1,56 +0,0 @@
use actix_session::{storage::CookieSessionStore, Session, SessionMiddleware};
use actix_web::{
cookie::{time::Duration, Key},
test, web, App, Responder,
};
async fn login(session: Session) -> impl Responder {
session.insert("user_id", "id").unwrap();
"Logged in"
}
async fn logout(session: Session) -> impl Responder {
session.purge();
"Logged out"
}
#[actix_web::test]
async fn cookie_storage() -> std::io::Result<()> {
let signing_key = Key::generate();
let app = test::init_service(
App::new()
.wrap(
SessionMiddleware::builder(CookieSessionStore::default(), signing_key.clone())
.cookie_path("/test".to_string())
.cookie_domain(Some("localhost".to_string()))
.build(),
)
.route("/login", web::post().to(login))
.route("/logout", web::post().to(logout)),
)
.await;
let login_request = test::TestRequest::post().uri("/login").to_request();
let login_response = test::call_service(&app, login_request).await;
let session_cookie = login_response.response().cookies().next().unwrap();
assert_eq!(session_cookie.name(), "id");
assert_eq!(session_cookie.path().unwrap(), "/test");
assert!(session_cookie.secure().unwrap());
assert!(session_cookie.http_only().unwrap());
assert!(session_cookie.max_age().is_none());
assert_eq!(session_cookie.domain().unwrap(), "localhost");
let logout_request = test::TestRequest::post()
.cookie(session_cookie)
.uri("/logout")
.to_request();
let logout_response = test::call_service(&app, logout_request).await;
let deletion_cookie = logout_response.response().cookies().next().unwrap();
assert_eq!(deletion_cookie.name(), "id");
assert_eq!(deletion_cookie.path().unwrap(), "/test");
assert!(deletion_cookie.secure().unwrap());
assert!(deletion_cookie.http_only().unwrap());
assert_eq!(deletion_cookie.max_age().unwrap(), Duration::ZERO);
assert_eq!(deletion_cookie.domain().unwrap(), "localhost");
Ok(())
}

View File

@ -1,93 +0,0 @@
use std::collections::HashMap;
use actix_session::{
storage::{LoadError, SaveError, SessionKey, SessionStore, UpdateError},
Session, SessionMiddleware,
};
use actix_web::{
body::MessageBody,
cookie::{time::Duration, Key},
dev::Service,
http::StatusCode,
test, web, App, Responder,
};
use anyhow::Error;
#[actix_web::test]
async fn errors_are_opaque() {
let signing_key = Key::generate();
let app = test::init_service(
App::new()
.wrap(SessionMiddleware::new(MockStore, signing_key.clone()))
.route("/create_session", web::post().to(create_session))
.route(
"/load_session_with_error",
web::post().to(load_session_with_error),
),
)
.await;
let req = test::TestRequest::post()
.uri("/create_session")
.to_request();
let response = test::call_service(&app, req).await;
let session_cookie = response.response().cookies().next().unwrap();
let req = test::TestRequest::post()
.cookie(session_cookie)
.uri("/load_session_with_error")
.to_request();
let response = app.call(req).await.unwrap_err().error_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(response.into_body().try_into_bytes().unwrap().is_empty());
}
struct MockStore;
impl SessionStore for MockStore {
async fn load(
&self,
_session_key: &SessionKey,
) -> Result<Option<HashMap<String, String>>, LoadError> {
Err(LoadError::Other(anyhow::anyhow!(
"My error full of implementation details"
)))
}
async fn save(
&self,
_session_state: HashMap<String, String>,
_ttl: &Duration,
) -> Result<SessionKey, SaveError> {
Ok("random_value".to_string().try_into().unwrap())
}
async fn update(
&self,
_session_key: SessionKey,
_session_state: HashMap<String, String>,
_ttl: &Duration,
) -> Result<SessionKey, UpdateError> {
#![allow(clippy::diverging_sub_expression)]
unimplemented!()
}
async fn update_ttl(&self, _session_key: &SessionKey, _ttl: &Duration) -> Result<(), Error> {
#![allow(clippy::diverging_sub_expression)]
unimplemented!()
}
async fn delete(&self, _session_key: &SessionKey) -> Result<(), Error> {
#![allow(clippy::diverging_sub_expression)]
unimplemented!()
}
}
async fn create_session(session: Session) -> impl Responder {
session.insert("user_id", "id").unwrap();
"Created"
}
async fn load_session_with_error(_session: Session) -> impl Responder {
"Loaded"
}

View File

@ -1,151 +0,0 @@
use actix_session::{SessionExt, SessionStatus};
use actix_web::{test, HttpResponse};
#[actix_web::test]
async fn session() {
let req = test::TestRequest::default().to_srv_request();
let session = req.get_session();
session.insert("key", "value").unwrap();
let res = session.get::<String>("key").unwrap();
assert_eq!(res, Some("value".to_string()));
session.insert("key2", "value2").unwrap();
session.remove("key");
let res = req.into_response(HttpResponse::Ok().finish());
let state: Vec<_> = res.get_session().entries().clone().into_iter().collect();
assert_eq!(
state.as_slice(),
[("key2".to_string(), "\"value2\"".to_string())]
);
}
#[actix_web::test]
async fn get_session() {
let req = test::TestRequest::default().to_srv_request();
let session = req.get_session();
session.insert("key", true).unwrap();
let res = session.get("key").unwrap();
assert_eq!(res, Some(true));
}
#[actix_web::test]
async fn get_session_from_request_head() {
let req = test::TestRequest::default().to_srv_request();
let session = req.get_session();
session.insert("key", 10).unwrap();
let res = session.get::<u32>("key").unwrap();
assert_eq!(res, Some(10));
}
#[actix_web::test]
async fn purge_session() {
let req = test::TestRequest::default().to_srv_request();
let session = req.get_session();
assert_eq!(session.status(), SessionStatus::Unchanged);
session.purge();
assert_eq!(session.status(), SessionStatus::Purged);
}
#[actix_web::test]
async fn renew_session() {
let req = test::TestRequest::default().to_srv_request();
let session = req.get_session();
assert_eq!(session.status(), SessionStatus::Unchanged);
session.renew();
assert_eq!(session.status(), SessionStatus::Renewed);
}
#[actix_web::test]
async fn session_entries() {
let req = test::TestRequest::default().to_srv_request();
let session = req.get_session();
session.insert("test_str", "val").unwrap();
session.insert("test_str", 1).unwrap();
let map = session.entries();
map.contains_key("test_str");
map.contains_key("test_num");
}
#[actix_web::test]
async fn session_contains_key() {
let req = test::TestRequest::default().to_srv_request();
let session = req.get_session();
session.insert("test_str", "val").unwrap();
session.insert("test_str", 1).unwrap();
assert!(session.contains_key("test_str"));
assert!(!session.contains_key("test_num"));
}
#[actix_web::test]
async fn insert_session_after_renew() {
let session = test::TestRequest::default().to_srv_request().get_session();
session.insert("test_val", "val").unwrap();
assert_eq!(session.status(), SessionStatus::Changed);
session.renew();
assert_eq!(session.status(), SessionStatus::Renewed);
session.insert("test_val1", "val1").unwrap();
assert_eq!(session.status(), SessionStatus::Renewed);
}
#[actix_web::test]
async fn update_session() {
let session = test::TestRequest::default().to_srv_request().get_session();
session.update("test_val", |c: u32| c + 1).unwrap();
assert_eq!(session.status(), SessionStatus::Unchanged);
session.insert("test_val", 0).unwrap();
assert_eq!(session.status(), SessionStatus::Changed);
session.update("test_val", |c: u32| c + 1).unwrap();
assert_eq!(session.get("test_val").unwrap(), Some(1));
session.update("test_val", |c: u32| c + 1).unwrap();
assert_eq!(session.get("test_val").unwrap(), Some(2));
}
#[actix_web::test]
async fn update_or_session() {
let session = test::TestRequest::default().to_srv_request().get_session();
session.update_or("test_val", 1, |c: u32| c + 1).unwrap();
assert_eq!(session.status(), SessionStatus::Changed);
assert_eq!(session.get("test_val").unwrap(), Some(1));
session.update_or("test_val", 1, |c: u32| c + 1).unwrap();
assert_eq!(session.get("test_val").unwrap(), Some(2));
}
#[actix_web::test]
async fn remove_session_after_renew() {
let session = test::TestRequest::default().to_srv_request().get_session();
session.insert("test_val", "val").unwrap();
session.remove("test_val").unwrap();
assert_eq!(session.status(), SessionStatus::Changed);
session.renew();
session.insert("test_val", "val").unwrap();
session.remove("test_val").unwrap();
assert_eq!(session.status(), SessionStatus::Renewed);
}
#[actix_web::test]
async fn clear_session_after_renew() {
let session = test::TestRequest::default().to_srv_request().get_session();
session.clear();
assert_eq!(session.status(), SessionStatus::Changed);
session.renew();
assert_eq!(session.status(), SessionStatus::Renewed);
session.clear();
assert_eq!(session.status(), SessionStatus::Renewed);
}

View File

@ -1,40 +0,0 @@
# Changes
## Unreleased
## 0.8.0
- Add `openssl` crate feature for TLS settings using OpenSSL.
- Add `ApplySettings::try_apply_settings()`.
- Implement TLS logic for `ApplySettings::try_apply_settings()`.
- Add `Tls::get_ssl_acceptor_builder()` function to build `openssl::ssl::SslAcceptorBuilder`.
- Deprecate `ApplySettings::apply_settings()`.
- Minimum supported Rust version (MSRV) is now 1.75.
## 0.7.1
- Fix doc examples.
## 0.7.0
- The `ApplySettings` trait now includes a type parameter, allowing multiple types to be implemented per configuration target.
- Implement `ApplySettings` for `ActixSettings`.
- `BasicSettings::from_default_template()` is now infallible.
- Rename `AtError => Error`.
- Remove `AtResult` type alias.
- Update `toml` dependency to `0.8`.
- Remove `ioe` dependency; `std::io::Error` is now used directly.
- Remove `Clone` implementation for `Error`.
- Implement `Display` for `Error`.
- Implement std's `Error` for `Error`.
- Minimum supported Rust version (MSRV) is now 1.68.
## 0.6.0
- Update Actix Web dependencies to v4 ecosystem.
- Rename `actix.ssl` settings object to `actix.tls`.
- `NoSettings` is now marked `#[non_exhaustive]`.
## 0.5.2
- Adopted into @actix org from <https://github.com/jjpe/actix-settings>.

View File

@ -1,37 +0,0 @@
[package]
name = "actix-settings"
version = "0.8.0"
authors = [
"Joey Ezechiels <joey.ezechiels@gmail.com>",
"Rob Ede <robjtede@icloud.com>",
]
description = "Easily manage Actix Web's settings from a TOML file and environment variables"
repository.workspace = true
homepage.workspace = true
license.workspace = true
edition.workspace = true
rust-version.workspace = true
[package.metadata.docs.rs]
rustdoc-args = ["--cfg", "docsrs"]
[features]
openssl = ["dep:openssl", "actix-web/openssl"]
[dependencies]
actix-http = "3"
actix-service = "2"
actix-web = { version = "4", default-features = false }
derive_more = { version = "2", features = ["display", "error"] }
once_cell = "1.21"
openssl = { version = "0.10", features = ["v110"], optional = true }
regex = "1.5"
serde = { version = "1", features = ["derive"] }
toml = "0.8"
[dev-dependencies]
actix-web = "4"
env_logger = "0.11"
[lints]
workspace = true

View File

@ -1 +0,0 @@
../LICENSE-APACHE

View File

@ -1 +0,0 @@
../LICENSE-MIT

View File

@ -1,31 +0,0 @@
# actix-settings
> Easily manage Actix Web's settings from a TOML file and environment variables.
<!-- prettier-ignore-start -->
[![crates.io](https://img.shields.io/crates/v/actix-settings?label=latest)](https://crates.io/crates/actix-settings)
[![Documentation](https://docs.rs/actix-settings/badge.svg?version=0.8.0)](https://docs.rs/actix-settings/0.8.0)
![Apache 2.0 or MIT licensed](https://img.shields.io/crates/l/actix-settings)
[![Dependency Status](https://deps.rs/crate/actix-settings/0.8.0/status.svg)](https://deps.rs/crate/actix-settings/0.8.0)
<!-- prettier-ignore-end -->
## Documentation & Resources
- [API Documentation](https://docs.rs/actix-settings)
- [Usage Example][usage]
- Minimum Supported Rust Version (MSRV): 1.57
### Custom Settings
There is a way to extend the available settings. This can be used to combine the settings provided by Actix Web and those provided by application server built using `actix`.
Have a look at [the usage example][usage] to see how.
## Special Thanks
This crate was made possible by support from Accept B.V and [@jjpe].
[usage]: https://github.com/actix/actix-extras/blob/master/actix-settings/examples/actix.rs
[@jjpe]: https://github.com/jjpe

View File

@ -1,82 +0,0 @@
use actix_settings::{ApplySettings as _, Mode, Settings};
use actix_web::{
get,
middleware::{Compress, Condition, Logger},
web, App, HttpServer, Responder,
};
#[get("/")]
async fn index(settings: web::Data<Settings>) -> impl Responder {
format!(
r#"{{
"mode": "{}",
"hosts": ["{}"]
}}"#,
match settings.actix.mode {
Mode::Development => "development",
Mode::Production => "production",
},
settings
.actix
.hosts
.iter()
.map(|addr| { format!("{}:{}", addr.host, addr.port) })
.collect::<Vec<_>>()
.join(", "),
)
.customize()
.insert_header(("content-type", "application/json"))
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let mut settings = Settings::parse_toml("./examples/config.toml")
.expect("Failed to parse `Settings` from config.toml");
// If the environment variable `$APPLICATION__HOSTS` is set,
// have its value override the `settings.actix.hosts` setting:
Settings::override_field_with_env_var(&mut settings.actix.hosts, "APPLICATION__HOSTS")?;
init_logger(&settings);
HttpServer::new({
// clone settings into each worker thread
let settings = settings.clone();
move || {
App::new()
// Include this `.wrap()` call for compression settings to take effect:
.wrap(Condition::new(
settings.actix.enable_compression,
Compress::default(),
))
.wrap(Logger::default())
// make `Settings` available to handlers
.app_data(web::Data::new(settings.clone()))
.service(index)
}
})
// apply the `Settings` to Actix Web's `HttpServer`
.try_apply_settings(&settings)?
.run()
.await
}
/// Initialize the logging infrastructure.
fn init_logger(settings: &Settings) {
if !settings.actix.enable_log {
return;
}
std::env::set_var(
"RUST_LOG",
match settings.actix.mode {
Mode::Development => "actix_web=debug",
Mode::Production => "actix_web=info",
},
);
std::env::set_var("RUST_BACKTRACE", "1");
env_logger::init();
}

Some files were not shown because too many files have changed in this diff Show More