mirror of
https://github.com/actix/actix-extras.git
synced 2025-04-21 17:46:49 +02:00
Compare commits
555 Commits
redis-v0.9
...
master
Author | SHA1 | Date | |
---|---|---|---|
|
c04cc19e73 | ||
|
6a13b3b182 | ||
|
d994912ac2 | ||
|
5f6f20cf37 | ||
|
5145924410 | ||
|
b20dec36ac | ||
|
f6e45d487b | ||
|
c53e198ea7 | ||
|
4d9984ee76 | ||
|
9a08090709 | ||
|
7d3348bb29 | ||
|
c0fa63af39 | ||
|
0b5e2b3647 | ||
|
b95595b9cd | ||
|
4b3f87e915 | ||
|
144c7f92b9 | ||
|
c71b9dd443 | ||
|
282d56e96b | ||
|
d514ad3af5 | ||
|
109e6a4793 | ||
|
bb0c7f21d9 | ||
|
3f7a479a76 | ||
|
fc4b656c3b | ||
|
0f35de7da1 | ||
|
8294fcc645 | ||
|
3de6b03711 | ||
|
64931189c7 | ||
|
265b213123 | ||
|
695369f02f | ||
|
87d9e51112 | ||
|
8c11d37dda | ||
|
d97b36652a | ||
|
98847b9279 | ||
|
cd1b77134e | ||
|
105932706d | ||
|
18f94fa8b5 | ||
|
66b82f0f30 | ||
|
d67abde5f3 | ||
|
3eafe7f5ce | ||
|
3b5f7ae68c | ||
|
036af488fd | ||
|
77406cbb71 | ||
|
2ede588693 | ||
|
21680e0ebe | ||
|
370f9d3033 | ||
|
8f4fb348b3 | ||
|
ff4b173716 | ||
|
49aacfce9f | ||
|
dd20ebb6cb | ||
|
a3211b73d3 | ||
|
a89d3a58bc | ||
|
3c640ec120 | ||
|
26ccf8b200 | ||
|
dd1421f1a0 | ||
|
4eb779be77 | ||
|
48646d1bd3 | ||
|
275675e1c2 | ||
|
50d2fee4e2 | ||
|
0c0d13be12 | ||
|
d10b71fe06 | ||
|
f2339971cd | ||
|
517e72f248 | ||
|
504e89403b | ||
|
31b1dc5aa8 | ||
|
d7daf441d1 | ||
|
2de4b1886c | ||
|
caa5dbc5b3 | ||
|
c259e715f8 | ||
|
d8a86751f0 | ||
|
cac93d2bc7 | ||
|
95f4e0f692 | ||
|
24f3985eab | ||
|
b0d2947a4a | ||
|
0802eff40d | ||
|
2a6a36af23 | ||
|
3ebdc6192c | ||
|
87cf947a45 | ||
|
f063bec5ba | ||
|
45e9e00285 | ||
|
6934db623b | ||
|
1a658a98e1 | ||
|
2a092a19a8 | ||
|
032aeb6fdb | ||
|
52e58610e4 | ||
|
023158cfa8 | ||
|
14c605fae2 | ||
|
d94c023bf9 | ||
|
7e21fd753e | ||
|
e7ee2a06ab | ||
|
8aa2c959c4 | ||
|
2f1d1daee8 | ||
|
d15572b501 | ||
|
b9e47d61c3 | ||
|
515a727ca3 | ||
|
20234ec555 | ||
|
e4bb5ed355 | ||
|
5368569d00 | ||
|
21b9408a23 | ||
|
5879740322 | ||
|
4adc9f8884 | ||
|
abf75eeb06 | ||
|
433c926503 | ||
|
8ebb12b75a | ||
|
931c4eea4d | ||
|
8195484415 | ||
|
3ae4ef2706 | ||
|
65c698cd7f | ||
|
f2ef72d056 | ||
|
e4ee236341 | ||
|
41ae57d414 | ||
|
1b82024499 | ||
|
6b04450703 | ||
|
c0c7588a57 | ||
|
b918084a53 | ||
|
b762b41360 | ||
|
da53492c8c | ||
|
2c81bc093b | ||
|
a2ef65715b | ||
|
9beb348d45 | ||
|
66544952b6 | ||
|
9ddb95b74a | ||
|
f450e3fb85 | ||
|
31951dcc9b | ||
|
dfc6fe1986 | ||
|
122fba0580 | ||
|
f250348e57 | ||
|
e6f99e915d | ||
|
9d68074bf1 | ||
|
bbb4ed047c | ||
|
39291c86b7 | ||
|
db2193b8c5 | ||
|
f0c33a970f | ||
|
74c8545363 | ||
|
9112cf9f23 | ||
|
563d6e0b20 | ||
|
a71c7f6a90 | ||
|
a5f5e31a82 | ||
|
5414e2655b | ||
|
daffc24245 | ||
|
2e0cbb8bbb | ||
|
7fe13e142e | ||
|
8ddbf26cc1 | ||
|
b9769edca1 | ||
|
e3027549c5 | ||
|
1934457e48 | ||
|
254d4084a9 | ||
|
a9e615bac4 | ||
|
1e70159e08 | ||
|
89bf63e1ef | ||
|
8b4e8ea34e | ||
|
5ceb3c72cd | ||
|
c62b271d9a | ||
|
320cbebc7e | ||
|
0c859a96c8 | ||
|
d55fc6d7f5 | ||
|
e2bf504055 | ||
|
77b8dcdf59 | ||
|
b694c9317a | ||
|
57eaad2ffe | ||
|
0cb0e28208 | ||
|
8049a75d9f | ||
|
0dd810e213 | ||
|
5bf831c27b | ||
|
a7e3503ad1 | ||
|
819f45106f | ||
|
4f76943423 | ||
|
2f30fd71a9 | ||
|
5198c68c06 | ||
|
2d4cf5f422 | ||
|
53dce5c34f | ||
|
8de686a711 | ||
|
50fd71d496 | ||
|
3c5478966f | ||
|
1e18d62852 | ||
|
7aeeb9a445 | ||
|
5b2085f414 | ||
|
6afca96ddf | ||
|
a48c2926f9 | ||
|
6d0ab96dfd | ||
|
a593a8dc90 | ||
|
4d79d263ef | ||
|
31540f8e4b | ||
|
11046d7663 | ||
|
73b2aac6d6 | ||
|
76d9313171 | ||
|
373a89a978 | ||
|
61f16c609a | ||
|
ecd2016c09 | ||
|
471f07e27f | ||
|
077c6edced | ||
|
fad631c448 | ||
|
20f72cab3e | ||
|
4bad825456 | ||
|
cb3eba93cc | ||
|
9d993c6c73 | ||
|
4761826616 | ||
|
ec340670a8 | ||
|
3a7834c3ba | ||
|
7db43782ce | ||
|
1ee1afb2a6 | ||
|
9e4754bbfa | ||
|
cd3e5f9772 | ||
|
45ee50f9cb | ||
|
1d6ef8938f | ||
|
316c0d238d | ||
|
5baa3c3d95 | ||
|
2dea1f2748 | ||
|
09ff35bd2d | ||
|
6caf37cedd | ||
|
bafd8179ff | ||
|
3fad53211a | ||
|
9a7113028e | ||
|
9e31f5b306 | ||
|
94f99e4843 | ||
|
600dda5ef3 | ||
|
2a074ddf18 | ||
|
9fc34a9c48 | ||
|
f942d8a191 | ||
|
b737452294 | ||
|
55ace79d64 | ||
|
c029287801 | ||
|
0d27e3a65a | ||
|
257871ca7a | ||
|
d921417726 | ||
|
70b46280ed | ||
|
55d70231cc | ||
|
aaedb9c625 | ||
|
75386f4a1d | ||
|
8a31f3020e | ||
|
8c93f5314b | ||
|
f37c93a2a8 | ||
|
111d95eaea | ||
|
8729f60f79 | ||
|
77ee27b4ae | ||
|
b948ac9f7a | ||
|
ad1f15eb18 | ||
|
8a9c604c03 | ||
|
218f18e69d | ||
|
2bc16eee18 | ||
|
713b157fd4 | ||
|
bf49b39740 | ||
|
441d604c00 | ||
|
1ed893a08c | ||
|
708aa945dc | ||
|
9be4f1ff73 | ||
|
f8a1165d10 | ||
|
d9175a0399 | ||
|
fe4d3d366d | ||
|
1036f54fd0 | ||
|
e9428ba261 | ||
|
779860b664 | ||
|
6848312467 | ||
|
8c509151f1 | ||
|
1774b8a36e | ||
|
9508be94d5 | ||
|
8e76c6c628 | ||
|
8fd166435f | ||
|
1ac325ab79 | ||
|
b95ce3a210 | ||
|
ac444ca798 | ||
|
fb8a814acb | ||
|
da0a806e8d | ||
|
d28ab6eaa1 | ||
|
a2c5cbd637 | ||
|
e6ef190510 | ||
|
3b5682c860 | ||
|
82a100d96c | ||
|
d98ebf2bdf | ||
|
1561bda822 | ||
|
339b81e843 | ||
|
eb3660a772 | ||
|
9a3b410409 | ||
|
32313c0af6 | ||
|
a623c50e9c | ||
|
7d932cd540 | ||
|
ffe122b76e | ||
|
1e682e7a59 | ||
|
e61dbae860 | ||
|
a325f5dd02 | ||
|
bad6159516 | ||
|
7c3c9357e0 | ||
|
bcb8dbe1fc | ||
|
983746f106 | ||
|
b054733854 | ||
|
ab3f591307 | ||
|
c08cd8a23a | ||
|
da32c1bb49 | ||
|
90766e5d68 | ||
|
f678842e46 | ||
|
e13b62fc6b | ||
|
6e79465362 | ||
|
cd9dc163e5 | ||
|
810a88a156 | ||
|
cfd16c5478 | ||
|
07c5176bd0 | ||
|
446c92c3d0 | ||
|
65a6252fec | ||
|
73732b0a62 | ||
|
ff06958b32 | ||
|
4d2f4d58b4 | ||
|
140453c649 | ||
|
fbae63d07f | ||
|
417c06b00e | ||
|
553c2bfb92 | ||
|
1089faaf93 | ||
|
1cc37c371e | ||
|
d853c115b6 | ||
|
603215095a | ||
|
d3fb564380 | ||
|
ee71d4cfa7 | ||
|
f39a64f526 | ||
|
d5dc087e93 | ||
|
169b262c66 | ||
|
d4384932ff | ||
|
4e1a95fc75 | ||
|
910f964100 | ||
|
3e002a677b | ||
|
ca9879425b | ||
|
ecd7756644 | ||
|
9bc014b96f | ||
|
e0ffd4e592 | ||
|
97ee544057 | ||
|
3b1c161547 | ||
|
1830f66dca | ||
|
c52ea7a5d2 | ||
|
b1cea64795 | ||
|
b8f4a658a9 | ||
|
69e4264e0c | ||
|
c2f068db66 | ||
|
d09299390a | ||
|
a42ca24327 | ||
|
3c48e00e7a | ||
|
7267a19b1d | ||
|
f6508f290c | ||
|
d11a272384 | ||
|
8fd1772d5e | ||
|
aebf9ccf58 | ||
|
4ca3e04929 | ||
|
04f4934001 | ||
|
4d77e26e1e | ||
|
8db1088345 | ||
|
ac821e65b1 | ||
|
5fdc4d8990 | ||
|
bf41b4cd9c | ||
|
449abd6081 | ||
|
977e3141c9 | ||
|
bb553b2308 | ||
|
e8ebf525ad | ||
|
2417549b35 | ||
|
92269fc308 | ||
|
01932f87d3 | ||
|
010a905dca | ||
|
2d63973654 | ||
|
a086d30db2 | ||
|
b748e7e3a7 | ||
|
6fbe2eab94 | ||
|
0ba1073cb2 | ||
|
7e6335a09f | ||
|
a1d0f051b7 | ||
|
e1f79dae17 | ||
|
12f6db3755 | ||
|
673b77a765 | ||
|
ce92f0036f | ||
|
fd272f817c | ||
|
2e86fb9822 | ||
|
9e911490cf | ||
|
ef8f33e3b3 | ||
|
c805dd7609 | ||
|
3959c55c47 | ||
|
73a5ae98b6 | ||
|
d46ee464e0 | ||
|
ff38ba2ce3 | ||
|
00378548ed | ||
|
cf0630b54d | ||
|
695800c9bd | ||
|
f9beeecaf6 | ||
|
d9f27120f6 | ||
|
f870074354 | ||
|
ab3615b8d5 | ||
|
7bb1f8d710 | ||
|
fac087723a | ||
|
50fe96f278 | ||
|
25d3d34927 | ||
|
0ac068a14d | ||
|
323d01fcca | ||
|
6abec48e29 | ||
|
f676fc7de5 | ||
|
dbc82001c0 | ||
|
717b93e507 | ||
|
7982abb71e | ||
|
e813e63138 | ||
|
bb900a41fc | ||
|
4e72e3d8ec | ||
|
09734a8d12 | ||
|
1fec026dc7 | ||
|
6d6b045b3a | ||
|
8540b61a13 | ||
|
cf7ed8e1b9 | ||
|
19bff0028e | ||
|
6d3ee78db5 | ||
|
ce2c97070e | ||
|
440ab34bd2 | ||
|
88859af6a5 | ||
|
422a264787 | ||
|
0d12201073 | ||
|
5f17026a55 | ||
|
92c9601850 | ||
|
6ca36b5d53 | ||
|
14a622092d | ||
|
6676a50944 | ||
|
c047cd5653 | ||
|
fad4426388 | ||
|
0805f2b1c6 | ||
|
a0c93c62b3 | ||
|
128fc49811 | ||
|
e83c488544 | ||
|
360fd890dc | ||
|
863d0b114b | ||
|
8fa073586e | ||
|
e77ed16f49 | ||
|
74ec115161 | ||
|
700d90b68b | ||
|
56051786a6 | ||
|
ec66754c0d | ||
|
e636af039a | ||
|
64acd3229f | ||
|
8bf2ae711a | ||
|
5272bc1c79 | ||
|
3cc1487a4a | ||
|
551cbfb113 | ||
|
0b4a4eeff6 | ||
|
37532fd1eb | ||
|
ddb74e6de0 | ||
|
13f8dcb717 | ||
|
07deaadd7b | ||
|
477b0f8f06 | ||
|
0999159877 | ||
|
7737da83e8 | ||
|
c6edb2a48a | ||
|
5d82b4e1e2 | ||
|
45643d4035 | ||
|
545873b5b2 | ||
|
627fe96be0 | ||
|
d0f2075ce9 | ||
|
a08b96529f | ||
|
1261557dc9 | ||
|
798a5d6d0e | ||
|
051929ab47 | ||
|
50621bae71 | ||
|
e10937103e | ||
|
c6f579790f | ||
|
44c7b07ce2 | ||
|
20ef05c36e | ||
|
64eec6e550 | ||
|
8e35b652d4 | ||
|
8741cd32cc | ||
|
727353213f | ||
|
d898e9e217 | ||
|
d0f0fb474b | ||
|
bb1cc7443f | ||
|
7ab2b62810 | ||
|
0ba14f786e | ||
|
554a852dea | ||
|
5624ac9bb0 | ||
|
fc6563a019 | ||
|
23912afd49 | ||
|
c7df62d0b6 | ||
|
15d72b1694 | ||
|
c8f1d9671c | ||
|
7d0df351e0 | ||
|
2254a429d4 | ||
|
b0854ed144 | ||
|
ca85f6b245 | ||
|
86ff1302ad | ||
|
5a72dd33d5 | ||
|
8d635f71fb | ||
|
ba248a681b | ||
|
81c48930c4 | ||
|
d77c908567 | ||
|
91e91c4327 | ||
|
cf970ee091 | ||
|
699846d965 | ||
|
936a116264 | ||
|
f970d90894 | ||
|
699a98493b | ||
|
a22e4cfa36 | ||
|
99e98f1a29 | ||
|
ab80a91468 | ||
|
f60c3653d3 | ||
|
c534ef0f98 | ||
|
cbfd5d94ee | ||
|
61778d864e | ||
|
a396c1b961 | ||
|
0b2eff9384 | ||
|
76429602c6 | ||
|
f20b724830 | ||
|
6084c47810 | ||
|
c8e641d4b6 | ||
|
b59e307de1 | ||
|
2d1a493f17 | ||
|
c57c97c7c8 | ||
|
06f17ec223 | ||
|
134e43ab5e | ||
|
16db1b6808 | ||
|
d02e508731 | ||
|
b33012999c | ||
|
0f7a147323 | ||
|
e3da3094f0 | ||
|
33dfea8997 | ||
|
d14e188246 | ||
|
f185a9c7e6 | ||
|
99fe08f332 | ||
|
bb8120a8c0 | ||
|
03ccf09e2e | ||
|
6ae147d190 | ||
|
400d889116 | ||
|
f3d5dfde40 | ||
|
7a26d99c1a | ||
|
4a546718aa | ||
|
bad5f32a68 | ||
|
7e6bdf2eb2 | ||
|
e5fe8d42fa | ||
|
693c2f5041 | ||
|
d25ae41525 | ||
|
a960eb0ef6 | ||
|
43ababef8f | ||
|
2ae3c80548 | ||
|
2a20ce4568 | ||
|
39fe80df40 | ||
|
61da910a81 | ||
|
6d3e4c9aa1 | ||
|
9b573131f8 | ||
|
f920479fdb | ||
|
bd963fb7d1 | ||
|
6b839f0a30 | ||
|
f4d75260a4 | ||
|
6e132d9337 | ||
|
95041b8e80 | ||
|
027f045340 | ||
|
70df190e0b | ||
|
1d32248844 | ||
|
5157927219 | ||
|
e03544cb0d | ||
|
a0166495ea | ||
|
baafcbe625 | ||
|
2209359c78 | ||
|
10fe10c9c1 | ||
|
b699506526 | ||
|
2e3a094ef7 | ||
|
c152c1ae8c | ||
|
4eb987f8c8 | ||
|
61d5586052 | ||
|
a50f1db473 | ||
|
5c6c04caf3 | ||
|
f3eaf5640c |
7
.cargo/config.toml
Normal file
7
.cargo/config.toml
Normal file
@ -0,0 +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 = "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"
|
43
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
43
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,43 @@
|
||||
---
|
||||
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.
|
||||
|
||||
## 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:
|
27
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
27
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
<!-- Thanks for considering contributing actix! -->
|
||||
<!-- 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
|
||||
|
||||
<!-- 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`).
|
||||
|
||||
## Overview
|
||||
|
||||
<!-- Describe the current and new behavior. -->
|
||||
<!-- Emphasize any breaking changes. -->
|
||||
|
||||
<!-- If this PR fixes or closes an issue, reference it here. -->
|
||||
<!-- Closes #000 -->
|
10
.github/dependabot.yml
vendored
Normal file
10
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: monthly
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
107
.github/workflows/ci-post-merge.yml
vendored
Normal file
107
.github/workflows/ci-post-merge.yml
vendored
Normal file
@ -0,0 +1,107 @@
|
||||
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
|
153
.github/workflows/ci.yml
vendored
Normal file
153
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,153 @@
|
||||
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:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target:
|
||||
- { name: Linux, os: ubuntu-latest, triple: x86_64-unknown-linux-gnu }
|
||||
version:
|
||||
- { name: msrv, version: 1.75.0 }
|
||||
- { name: stable, version: stable }
|
||||
|
||||
name: ${{ matrix.target.name }} / ${{ matrix.version.name }}
|
||||
runs-on: ${{ matrix.target.os }}
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:6
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--entrypoint redis-server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust (${{ matrix.version.name }})
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
|
||||
with:
|
||||
toolchain: ${{ matrix.version.version }}
|
||||
|
||||
- name: Install cargo-hack and cargo-ci-cache-clean, just
|
||||
uses: taiki-e/install-action@v2.49.42
|
||||
with:
|
||||
tool: cargo-hack,cargo-ci-cache-clean,just
|
||||
|
||||
- name: workaround MSRV issues
|
||||
if: matrix.version.name == 'msrv'
|
||||
run: just downgrade-for-msrv
|
||||
|
||||
- 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:
|
||||
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 }
|
||||
|
||||
name: ${{ matrix.target.name }} / ${{ matrix.version.name }}
|
||||
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 (${{ matrix.version.name }})
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
|
||||
with:
|
||||
toolchain: ${{ matrix.version.version }}
|
||||
|
||||
- name: Install cargo-hack, cargo-ci-cache-clean, just
|
||||
uses: taiki-e/install-action@v2.49.42
|
||||
with:
|
||||
tool: cargo-hack,cargo-ci-cache-clean,just
|
||||
|
||||
- name: workaround MSRV issues
|
||||
if: matrix.version.name == 'msrv'
|
||||
run: just downgrade-for-msrv
|
||||
|
||||
- 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
|
||||
|
||||
- name: CI cache clean
|
||||
run: cargo-ci-cache-clean
|
||||
|
||||
doc_tests:
|
||||
name: Documentation Tests
|
||||
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
|
||||
|
||||
- name: Install just
|
||||
uses: taiki-e/install-action@v2.49.42
|
||||
with:
|
||||
tool: just
|
||||
|
||||
- name: Test docs
|
||||
run: just test-docs
|
||||
|
||||
- name: Build docs
|
||||
run: just doc
|
18
.github/workflows/clippy.yml
vendored
18
.github/workflows/clippy.yml
vendored
@ -1,18 +0,0 @@
|
||||
on: pull_request
|
||||
|
||||
name: Clippy Check
|
||||
jobs:
|
||||
clippy_check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
profile: minimal
|
||||
components: clippy
|
||||
override: true
|
||||
- uses: actions-rs/clippy-check@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
args: --all-features --all --tests
|
47
.github/workflows/coverage.yml
vendored
Normal file
47
.github/workflows/coverage.yml
vendored
Normal file
@ -0,0 +1,47 @@
|
||||
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
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:5.0.7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --entrypoint redis-server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
|
||||
with:
|
||||
toolchain: nightly
|
||||
components: llvm-tools-preview
|
||||
|
||||
- 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: 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 }}
|
48
.github/workflows/lint.yml
vendored
Normal file
48
.github/workflows/lint.yml
vendored
Normal file
@ -0,0 +1,48 @@
|
||||
name: Lint
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Check with Clippy
|
||||
uses: giraffate/clippy-action@v1.0.1
|
||||
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
|
82
.github/workflows/linux.yml
vendored
82
.github/workflows/linux.yml
vendored
@ -1,82 +0,0 @@
|
||||
name: CI (Linux)
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build_and_test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- stable
|
||||
- nightly
|
||||
|
||||
name: ${{ matrix.version }} - x86_64-unknown-linux-gnu
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:5.0.7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --entrypoint redis-server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Install ${{ matrix.version }}
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ matrix.version }}-x86_64-unknown-linux-gnu
|
||||
profile: minimal
|
||||
override: true
|
||||
|
||||
- name: Generate Cargo.lock
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: generate-lockfile
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cargo/registry
|
||||
key: ${{ matrix.version }}-x86_64-unknown-linux-gnu-cargo-registry-trimmed-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Cache cargo index
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cargo/git
|
||||
key: ${{ matrix.version }}-x86_64-unknown-linux-gnu-cargo-index-trimmed-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Cache cargo build
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: target
|
||||
key: ${{ matrix.version }}-x86_64-unknown-linux-gnu-cargo-build-trimmed-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: check build
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: check
|
||||
args: --all --bins --examples --tests
|
||||
|
||||
- name: tests
|
||||
uses: actions-rs/cargo@v1
|
||||
timeout-minutes: 40
|
||||
with:
|
||||
command: test
|
||||
args: --all --all-features --no-fail-fast -- --nocapture
|
||||
|
||||
- name: Generate coverage file
|
||||
if: matrix.version == 'stable' && (github.ref == 'master' || github.event_name == 'pull_request')
|
||||
run: |
|
||||
cargo install cargo-tarpaulin
|
||||
cargo tarpaulin --out Xml --workspace --all-features
|
||||
|
||||
- name: Upload to Codecov
|
||||
if: matrix.version == 'stable' && (github.ref == 'master' || github.event_name == 'pull_request')
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
file: cobertura.xml
|
||||
|
||||
- name: Clear the cargo caches
|
||||
run: |
|
||||
cargo install cargo-cache --no-default-features --features ci-autoclean
|
||||
cargo-cache
|
66
.github/workflows/macos.yml
vendored
66
.github/workflows/macos.yml
vendored
@ -1,66 +0,0 @@
|
||||
name: CI (macOS)
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build_and_test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- stable
|
||||
- nightly
|
||||
|
||||
name: ${{ matrix.version }} - x86_64-apple-darwin
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Install ${{ matrix.version }}
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ matrix.version }}-x86_64-apple-darwin
|
||||
profile: minimal
|
||||
override: true
|
||||
|
||||
- name: Generate Cargo.lock
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: generate-lockfile
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cargo/registry
|
||||
key: ${{ matrix.version }}-x86_64-apple-darwin-cargo-registry-trimmed-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Cache cargo index
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cargo/git
|
||||
key: ${{ matrix.version }}-x86_64-apple-darwin-cargo-index-trimmed-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Cache cargo build
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: target
|
||||
key: ${{ matrix.version }}-x86_64-apple-darwin-cargo-build-trimmed-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: check build
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: check
|
||||
args: --all --bins --examples --tests
|
||||
|
||||
- name: tests
|
||||
uses: actions-rs/cargo@v1
|
||||
timeout-minutes: 40
|
||||
with:
|
||||
command: test
|
||||
args: --package=actix-cors
|
||||
--package=actix-protobuf
|
||||
--package=actix-web-httpauth
|
||||
--all-features --no-fail-fast -- --nocapture
|
||||
|
||||
- name: Clear the cargo caches
|
||||
run: |
|
||||
cargo install cargo-cache --no-default-features --features ci-autoclean
|
||||
cargo-cache
|
43
.github/workflows/msrv.yml
vendored
43
.github/workflows/msrv.yml
vendored
@ -1,43 +0,0 @@
|
||||
name: CI (Linux, MSRV)
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build_and_test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- 1.40.0
|
||||
|
||||
name: ${{ matrix.version }} - x86_64-unknown-linux-gnu
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:5.0.7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --entrypoint redis-server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Install ${{ matrix.version }}
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ matrix.version }}-x86_64-unknown-linux-gnu
|
||||
profile: minimal
|
||||
override: true
|
||||
|
||||
- name: tests (1.40.0)
|
||||
if: matrix.version == '1.40.0'
|
||||
uses: actions-rs/cargo@v1
|
||||
timeout-minutes: 40
|
||||
with:
|
||||
command: test
|
||||
args: --package=actix-cors
|
||||
--package=actix-protobuf
|
||||
--package=actix-redis
|
||||
--package=actix-web-httpauth
|
||||
--all-features --no-fail-fast -- --nocapture
|
35
.github/workflows/upload-doc.yml
vendored
35
.github/workflows/upload-doc.yml
vendored
@ -1,35 +0,0 @@
|
||||
name: Upload documentation (actix-redis)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'actix/actix-extras'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable-x86_64-unknown-linux-gnu
|
||||
profile: minimal
|
||||
override: true
|
||||
|
||||
- name: check build
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: doc
|
||||
args: --no-deps --package=actix-redis
|
||||
|
||||
- name: Tweak HTML
|
||||
run: echo "<meta http-equiv=refresh content=0;url=os_balloon/index.html>" > target/doc/index.html
|
||||
|
||||
- name: Upload documentation
|
||||
run: |
|
||||
git clone https://github.com/davisp/ghp-import.git
|
||||
./ghp-import/ghp_import.py -n -p -f -m "Documentation upload" -r https://${{ secrets.GITHUB_TOKEN }}@github.com/"${{ github.repository }}.git" target/doc
|
70
.github/workflows/windows.yml
vendored
70
.github/workflows/windows.yml
vendored
@ -1,70 +0,0 @@
|
||||
name: CI (Windows)
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build_and_test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- stable
|
||||
- nightly
|
||||
target:
|
||||
- x86_64-pc-windows-msvc
|
||||
- x86_64-pc-windows-gnu
|
||||
- i686-pc-windows-msvc
|
||||
|
||||
name: ${{ matrix.version }} - ${{ matrix.target }}
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Install ${{ matrix.version }}
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ matrix.version }}-${{ matrix.target }}
|
||||
profile: minimal
|
||||
override: true
|
||||
|
||||
- name: Generate Cargo.lock
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: generate-lockfile
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cargo/registry
|
||||
key: ${{ matrix.version }}-${{ matrix.target }}-cargo-registry-trimmed-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Cache cargo index
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cargo/git
|
||||
key: ${{ matrix.version }}-${{ matrix.target }}-cargo-index-trimmed-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Cache cargo build
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: target
|
||||
key: ${{ matrix.version }}-${{ matrix.target }}-cargo-build-trimmed-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: check build
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: check
|
||||
args: --all --bins --examples --tests
|
||||
|
||||
- name: tests
|
||||
uses: actions-rs/cargo@v1
|
||||
timeout-minutes: 40
|
||||
with:
|
||||
command: test
|
||||
args: --package=actix-cors
|
||||
--package=actix-protobuf
|
||||
--package=actix-web-httpauth
|
||||
--all-features --no-fail-fast -- --nocapture
|
||||
|
||||
- name: Clear the cargo caches
|
||||
run: |
|
||||
cargo install cargo-cache --no-default-features --features ci-autoclean
|
||||
cargo-cache
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,6 +1,5 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
Cargo.lock
|
||||
|
||||
guide/build/
|
||||
/gh-pages
|
||||
@ -11,3 +10,6 @@ guide/build/
|
||||
*.pid
|
||||
*.sock
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
Server.toml
|
||||
|
5
.prettierrc.yml
Normal file
5
.prettierrc.yml
Normal file
@ -0,0 +1,5 @@
|
||||
overrides:
|
||||
- files: "*.md"
|
||||
options:
|
||||
proseWrap: never
|
||||
printWidth: 9999
|
3292
Cargo.lock
generated
Normal file
3292
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
Cargo.toml
43
Cargo.toml
@ -1,10 +1,39 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"actix-cors",
|
||||
"actix-identity",
|
||||
"actix-protobuf",
|
||||
"actix-protobuf/examples/prost-example",
|
||||
"actix-redis",
|
||||
"actix-session",
|
||||
"actix-web-httpauth",
|
||||
"actix-cors",
|
||||
"actix-identity",
|
||||
"actix-limitation",
|
||||
"actix-protobuf",
|
||||
"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" }
|
||||
|
||||
# uncomment to quickly test against local actix-web repo
|
||||
# actix-http = { path = "../actix-web/actix-http" }
|
||||
# actix-router = { path = "../actix-web/actix-router" }
|
||||
# actix-web = { path = "../actix-web" }
|
||||
# awc = { path = "../actix-web/awc" }
|
||||
|
@ -186,8 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2017-NOW Nikolay Kim
|
||||
Copyright 2017-NOW svartalf and Actix team
|
||||
Copyright 2017-NOW Actix team
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -1,5 +1,4 @@
|
||||
Copyright (c) 2017 Nikolay Kim
|
||||
Copyright (c) 2017 svartalf and Actix team
|
||||
Copyright (c) 2023 Actix team
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
|
97
README.md
97
README.md
@ -1,29 +1,90 @@
|
||||
# actix-extras
|
||||
|
||||
[](https://gitter.im/actix/actix?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)[](https://github.com/actix/actix-extras/actions)
|
||||
> A collection of additional crates supporting [Actix Web].
|
||||
|
||||
> A collection of additional crates supporting the [actix] and [actix-web] frameworks.
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
[](https://github.com/actix/actix-extras/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/actix/actix-extras)
|
||||
[](https://discord.gg/5Ux4QGChWc)
|
||||
[](https://deps.rs/repo/github/actix/actix-extras)
|
||||
|
||||
## Crates
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
| Crate | | |
|
||||
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| [actix-cors] | [](https://crates.io/crates/actix-cors) [](https://docs.rs/actix-cors) | Cross-origin resource sharing (CORS) for actix-web applications. |
|
||||
| [actix-identity] | [](https://crates.io/crates/actix-identity) [](https://docs.rs/actix-identity) | Identity service for actix-web framework. |
|
||||
| [actix-protobuf] | [](https://crates.io/crates/actix-protobuf) [](https://docs.rs/actix-protobuf) | Protobuf support for actix-web framework. |
|
||||
| [actix-redis] | [](https://crates.io/crates/actix-redis) [](https://docs.rs/actix-redis) | Redis integration for actix framework. |
|
||||
| [actix-session] | [](https://crates.io/crates/actix-session) [](https://docs.rs/actix-session) | Session for actix-web framework. |
|
||||
| [actix-web-httpauth] | [](https://crates.io/crates/actix-web-httpauth) [](https://docs.rs/actix-web-httpauth) | HTTP authentication schemes for actix-web. |
|
||||
## Crates by @actix
|
||||
|
||||
| Crate | | |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| [actix-cors] | [](https://crates.io/crates/actix-cors) [](https://deps.rs/crate/actix-cors) | Cross-Origin Resource Sharing (CORS) controls. |
|
||||
| [actix-identity] | [](https://crates.io/crates/actix-identity) [](https://deps.rs/crate/actix-identity) | Identity management. |
|
||||
| [actix-limitation] | [](https://crates.io/crates/actix-limitation) [](https://deps.rs/crate/actix-limitation) | Rate-limiting using a fixed window counter for arbitrary keys, backed by Redis. |
|
||||
| [actix-protobuf] | [](https://crates.io/crates/actix-protobuf) [](https://deps.rs/crate/actix-protobuf) | Protobuf payload extractor. |
|
||||
| [actix-session] | [](https://crates.io/crates/actix-session) [](https://deps.rs/crate/actix-session) | Session management. |
|
||||
| [actix-settings] | [](https://crates.io/crates/actix-settings) [](https://deps.rs/crate/actix-settings) | Easily manage Actix Web's settings from a TOML file and environment variables. |
|
||||
| [actix-web-httpauth] | [](https://crates.io/crates/actix-web-httpauth) [](https://deps.rs/crate/actix-web-httpauth) | HTTP authentication schemes. |
|
||||
| [actix-ws] | [][actix-ws] [](https://deps.rs/crate/actix-ws) | WebSockets for Actix Web, without actors. |
|
||||
|
||||
---
|
||||
|
||||
## Community Crates
|
||||
|
||||
These crates are provided by the community.
|
||||
|
||||
| Crate | | |
|
||||
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| [actix-web-lab] | [][actix-web-lab] [](https://deps.rs/crate/actix-web-lab) | Experimental extractors, middleware, and other extras for possible inclusion in Actix Web. |
|
||||
| [actix-form-data] | [][actix-form-data] [](https://deps.rs/crate/actix-form-data) | Multipart form data from actix multipart streams. |
|
||||
| [actix-governor] | [][actix-governor] [](https://deps.rs/crate/actix-governor) | Rate-limiting backed by governor. |
|
||||
| [actix-casbin] | [][actix-casbin] [](https://deps.rs/crate/actix-casbin) | Authorization library that supports access control models like ACL, RBAC & ABAC. |
|
||||
| [actix-ip-filter] | [][actix-ip-filter] [](https://deps.rs/crate/actix-ip-filter) | IP address filter. Supports glob patterns. |
|
||||
| [actix-web-static-files] | [][actix-web-static-files] [](https://deps.rs/crate/actix-web-static-files) | Static files as embedded resources. |
|
||||
| [actix-web-grants] | [][actix-web-grants] [](https://deps.rs/crate/actix-web-grants) | Extension for validating user authorities. |
|
||||
| [aliri_actix] | [][aliri_actix] [](https://deps.rs/crate/aliri_actix) | Endpoint authorization and authentication using scoped OAuth2 JWT tokens. |
|
||||
| [actix-web-flash-messages] | [][actix-web-flash-messages] [](https://deps.rs/crate/actix-web-flash-messages) | Support for flash messages/one-time notifications in `actix-web`. |
|
||||
| [awmp] | [][awmp] [](https://deps.rs/crate/awmp) | An easy to use wrapper around multipart fields for Actix Web. |
|
||||
| [tracing-actix-web] | [][tracing-actix-web] [](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] | [][actix-hash] [](https://deps.rs/crate/actix-hash) | Hashing utilities for Actix Web. |
|
||||
| [actix-bincode] |  [](https://deps.rs/crate/actix-bincode) | Bincode payload extractor for Actix Web. |
|
||||
| [sentinel-actix] |  [](https://deps.rs/crate/sentinel-actix) | General and flexible protection for Actix Web. |
|
||||
| [actix-telepathy] |  [](https://deps.rs/crate/actix-telepathy) | Build distributed applications with `RemoteActors` and `RemoteMessages`. |
|
||||
| [apistos] |  [](https://deps.rs/crate/apistos) | Automatic OpenAPI v3 documentation for Actix Web. |
|
||||
| [actix-web-validation] |  [](https://deps.rs/crate/actix-web-validation) | Request validation for Actix Web. |
|
||||
| [actix-jwt-cookies] |  [](https://deps.rs/repo/github/Necoo33/actix-jwt-cookies?path=%2F) | Store your data in encrypted cookies and get it elegantly. |
|
||||
| [actix-ws-broadcaster] |  [](https://deps.rs/repo/github/Necoo33/actix-ws-broadcaster?path=%2F) | A broadcaster library for actix-ws that includes grouping and conditional broadcasting. |
|
||||
|
||||
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-protobuf]: actix-protobuf
|
||||
[actix-redis]: actix-redis
|
||||
[actix-session]: actix-session
|
||||
[actix-web-httpauth]: actix-web-httpauth
|
||||
[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
|
||||
|
@ -1,24 +1,181 @@
|
||||
# Changes
|
||||
|
||||
## [unreleased]
|
||||
## Unreleased
|
||||
|
||||
* Minimum supported Rust version(MSRV) is now 1.40.0.
|
||||
## 0.7.1
|
||||
|
||||
## [0.3.0-alpha.1] - 2020-03-11
|
||||
- Implement `PartialEq` for `Cors` allowing for better testing.
|
||||
|
||||
* Minimize `futures-*` dependencies
|
||||
* Update `actix-web` dependency to 3.0.0-alpha.1
|
||||
## 0.7.0
|
||||
|
||||
## [0.2.0] - 2019-12-20
|
||||
- `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.
|
||||
|
||||
* Release
|
||||
## 0.6.5
|
||||
|
||||
## [0.2.0-alpha.3] - 2019-12-07
|
||||
- Fix `Vary` header when Private Network Access is enabled.
|
||||
- Minimum supported Rust version (MSRV) is now 1.68.
|
||||
|
||||
* Migrate to actix-web 2.0.0
|
||||
## 0.6.4
|
||||
|
||||
* Bump `derive_more` crate version to 0.99.0
|
||||
- Add `Cors::allow_private_network_access()` behind an unstable flag (`draft-private-network-access`).
|
||||
|
||||
## [0.1.0] - 2019-06-15
|
||||
## 0.6.3
|
||||
|
||||
* Move cors middleware to separate crate
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
- Minimum supported Rust version (MSRV) is now 1.54.
|
||||
|
||||
## 0.6.0-beta.7
|
||||
|
||||
- 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
|
||||
|
||||
- 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)_
|
||||
|
||||
- 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
|
||||
|
||||
- No significant changes since `0.6.0-beta.3`.
|
||||
|
||||
## 0.6.0-beta.3
|
||||
|
||||
- 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]
|
||||
- Minimum supported Rust version (MSRV) is now 1.52.
|
||||
|
||||
[#195]: https://github.com/actix/actix-extras/pull/195
|
||||
[#203]: https://github.com/actix/actix-extras/pull/203
|
||||
[#204]: https://github.com/actix/actix-extras/pull/204
|
||||
|
||||
## 0.6.0-beta.2
|
||||
|
||||
- No notable changes.
|
||||
|
||||
## 0.6.0-beta.1
|
||||
|
||||
- Update `actix-web` dependency to 4.0.0 beta.
|
||||
- Minimum supported Rust version (MSRV) is now 1.46.0.
|
||||
|
||||
</details>
|
||||
|
||||
## 0.5.4
|
||||
|
||||
- Fix `expose_any_header` method, now set the correct field. [#143]
|
||||
|
||||
[#143]: https://github.com/actix/actix-extras/pull/143
|
||||
|
||||
## 0.5.3
|
||||
|
||||
- Fix version spec for `derive_more` dependency.
|
||||
|
||||
## 0.5.2
|
||||
|
||||
- Ensure `tinyvec` is using the correct features.
|
||||
- Bump `futures-util` minimum version to `0.3.7` to avoid `RUSTSEC-2020-0059`.
|
||||
|
||||
## 0.5.1
|
||||
|
||||
- Fix `allow_any_header` method, now set the correct field. [#121]
|
||||
|
||||
[#121]: https://github.com/actix/actix-extras/pull/121
|
||||
|
||||
## 0.5.0
|
||||
|
||||
- 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]
|
||||
- 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]
|
||||
|
||||
[#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
|
||||
|
||||
- Allow closures to be used with `allowed_origin_fn`. [#110]
|
||||
|
||||
[#110]: https://github.com/actix/actix-extras/pull/110
|
||||
|
||||
## 0.4.0
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
- Minimize `futures-*` dependencies
|
||||
- Update `actix-web` dependency to 3.0.0-alpha.1
|
||||
|
||||
## 0.2.0 - 2019-12-20
|
||||
|
||||
- Release
|
||||
|
||||
## 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
|
||||
|
||||
- Move cors middleware to separate crate
|
||||
|
@ -1,25 +1,39 @@
|
||||
[package]
|
||||
name = "actix-cors"
|
||||
version = "0.3.0-alpha.1"
|
||||
authors = ["Nikolay Kim <fafhrd91@gmail.com>"]
|
||||
description = "Cross-origin resource sharing (CORS) for actix-web applications."
|
||||
readme = "README.md"
|
||||
keywords = ["cors", "web", "framework"]
|
||||
homepage = "https://actix.rs"
|
||||
repository = "https://github.com/actix/actix-extras.git"
|
||||
documentation = "https://docs.rs/actix-cors/"
|
||||
license = "MIT/Apache-2.0"
|
||||
edition = "2018"
|
||||
version = "0.7.1"
|
||||
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
|
||||
|
||||
[lib]
|
||||
name = "actix_cors"
|
||||
path = "src/lib.rs"
|
||||
[package.metadata.docs.rs]
|
||||
rustdoc-args = ["--cfg", "docsrs"]
|
||||
all-features = true
|
||||
|
||||
[features]
|
||||
draft-private-network-access = []
|
||||
|
||||
[dependencies]
|
||||
actix-web = "3.0.0-alpha.1"
|
||||
actix-service = "1.0.1"
|
||||
derive_more = "0.99.2"
|
||||
futures-util = { version = "0.3.4", default-features = false }
|
||||
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"] }
|
||||
log = "0.4"
|
||||
once_cell = "1"
|
||||
smallvec = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
actix-rt = "1.0.0"
|
||||
actix-web = { version = "4", default-features = false, features = ["macros"] }
|
||||
env_logger = "0.11"
|
||||
regex = "1.4"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
@ -1,17 +1,72 @@
|
||||
# actix-cors
|
||||
|
||||
[](https://crates.io/crates/actix-cors)
|
||||
[](https://docs.rs/actix-cors)
|
||||
[](https://deps.rs/crate/actix-cors/0.2.0)
|
||||

|
||||
[](https://gitter.im/actix/actix?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
> Cross-origin resource sharing (CORS) for Actix applications.
|
||||
[](https://crates.io/crates/actix-cors)
|
||||
[](https://docs.rs/actix-cors/0.7.1)
|
||||

|
||||

|
||||
<br />
|
||||
[](https://deps.rs/crate/actix-cors/0.7.1)
|
||||
[](https://crates.io/crates/actix-cors)
|
||||
[](https://discord.gg/NWpN5mmg3x)
|
||||
|
||||
## Documentation & community resources
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
* [User Guide](https://actix.rs/docs/)
|
||||
* [API Documentation](https://docs.rs/actix-cors/)
|
||||
* [Chat on gitter](https://gitter.im/actix/actix)
|
||||
* Cargo package: [actix-cors](https://crates.io/crates/actix-cors)
|
||||
* Minimum supported Rust version: 1.40.0 or later
|
||||
<!-- 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 & 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
|
||||
|
54
actix-cors/examples/cors.rs
Normal file
54
actix-cors/examples/cors.rs
Normal file
@ -0,0 +1,54 @@
|
||||
use actix_cors::Cors;
|
||||
use actix_web::{http::header, middleware::Logger, web, App, HttpServer};
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
|
||||
|
||||
log::info!("starting HTTP server at http://localhost:8080");
|
||||
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
// `permissive` is a wide-open development config
|
||||
// .wrap(Cors::permissive())
|
||||
.wrap(
|
||||
// default settings are overly restrictive to reduce chance of
|
||||
// misconfiguration leading to security concerns
|
||||
Cors::default()
|
||||
// add specific origin to allowed origin list
|
||||
.allowed_origin("http://project.local:8080")
|
||||
// allow any port on localhost
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
origin.as_bytes().starts_with(b"http://localhost")
|
||||
|
||||
// manual alternative:
|
||||
// unwrapping is acceptable on the origin header since this function is
|
||||
// only called when it exists
|
||||
// req_head
|
||||
// .headers()
|
||||
// .get(header::ORIGIN)
|
||||
// .unwrap()
|
||||
// .as_bytes()
|
||||
// .starts_with(b"http://localhost")
|
||||
})
|
||||
// set allowed methods list
|
||||
.allowed_methods(vec!["GET", "POST"])
|
||||
// set allowed request header list
|
||||
.allowed_headers(&[header::AUTHORIZATION, header::ACCEPT])
|
||||
// add header to allowed list
|
||||
.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),
|
||||
)
|
||||
.wrap(Logger::default())
|
||||
.default_service(web::to(|| async { "Hello, cross-origin world!" }))
|
||||
})
|
||||
.workers(1)
|
||||
.bind(("127.0.0.1", 8080))?
|
||||
.run()
|
||||
.await
|
||||
}
|
55
actix-cors/src/all_or_some.rs
Normal file
55
actix-cors/src/all_or_some.rs
Normal file
@ -0,0 +1,55 @@
|
||||
/// An enum signifying that some of type `T` is allowed, or `All` (anything is allowed).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AllOrSome<T> {
|
||||
/// Everything is allowed. Usually equivalent to the `*` value.
|
||||
All,
|
||||
|
||||
/// Only some of `T` is allowed
|
||||
Some(T),
|
||||
}
|
||||
|
||||
/// Default as `AllOrSome::All`.
|
||||
impl<T> Default for AllOrSome<T> {
|
||||
fn default() -> Self {
|
||||
AllOrSome::All
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> AllOrSome<T> {
|
||||
/// Returns whether this is an `All` variant.
|
||||
pub fn is_all(&self) -> bool {
|
||||
matches!(self, AllOrSome::All)
|
||||
}
|
||||
|
||||
/// Returns whether this is a `Some` variant.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_some(&self) -> bool {
|
||||
!self.is_all()
|
||||
}
|
||||
|
||||
/// Provides a shared reference to `T` if variant is `Some`.
|
||||
pub fn as_ref(&self) -> Option<&T> {
|
||||
match *self {
|
||||
AllOrSome::All => None,
|
||||
AllOrSome::Some(ref t) => Some(t),
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides a mutable reference to `T` if variant is `Some`.
|
||||
pub fn as_mut(&mut self) -> Option<&mut T> {
|
||||
match *self {
|
||||
AllOrSome::All => None,
|
||||
AllOrSome::Some(ref mut t) => Some(t),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn tests() {
|
||||
assert!(AllOrSome::<()>::All.is_all());
|
||||
assert!(!AllOrSome::<()>::All.is_some());
|
||||
|
||||
assert!(!AllOrSome::Some(()).is_all());
|
||||
assert!(AllOrSome::Some(()).is_some());
|
||||
}
|
702
actix-cors/src/builder.rs
Normal file
702
actix-cors/src/builder.rs
Normal file
@ -0,0 +1,702 @@
|
||||
use std::{collections::HashSet, rc::Rc};
|
||||
|
||||
use actix_utils::future::{self, Ready};
|
||||
use actix_web::{
|
||||
body::{EitherBody, MessageBody},
|
||||
dev::{RequestHead, Service, ServiceRequest, ServiceResponse, Transform},
|
||||
error::HttpError,
|
||||
http::{
|
||||
header::{HeaderName, HeaderValue},
|
||||
Method, Uri,
|
||||
},
|
||||
Either, Error, Result,
|
||||
};
|
||||
use log::error;
|
||||
use once_cell::sync::Lazy;
|
||||
use smallvec::smallvec;
|
||||
|
||||
use crate::{AllOrSome, CorsError, CorsMiddleware, Inner, OriginFn};
|
||||
|
||||
/// Convenience for getting mut refs to inner. Cleaner than `Rc::get_mut`.
|
||||
/// Additionally, always causes first error (if any) to be reported during initialization.
|
||||
fn cors<'a>(
|
||||
inner: &'a mut Rc<Inner>,
|
||||
err: &Option<Either<HttpError, CorsError>>,
|
||||
) -> Option<&'a mut Inner> {
|
||||
if err.is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Rc::get_mut(inner)
|
||||
}
|
||||
|
||||
static ALL_METHODS_SET: Lazy<HashSet<Method>> = Lazy::new(|| {
|
||||
HashSet::from_iter(vec![
|
||||
Method::GET,
|
||||
Method::POST,
|
||||
Method::PUT,
|
||||
Method::DELETE,
|
||||
Method::HEAD,
|
||||
Method::OPTIONS,
|
||||
Method::CONNECT,
|
||||
Method::PATCH,
|
||||
Method::TRACE,
|
||||
])
|
||||
});
|
||||
|
||||
/// Builder for CORS middleware.
|
||||
///
|
||||
/// To construct a CORS middleware, call [`Cors::default()`] to create a blank, restrictive builder.
|
||||
/// Then use any of the builder methods to customize CORS behavior.
|
||||
///
|
||||
/// 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;
|
||||
///
|
||||
/// let cors = Cors::default()
|
||||
/// .allowed_origin("https://www.rust-lang.org")
|
||||
/// .allowed_methods(vec!["GET", "POST"])
|
||||
/// .allowed_headers(vec![header::AUTHORIZATION, header::ACCEPT])
|
||||
/// .allowed_header(header::CONTENT_TYPE)
|
||||
/// .max_age(3600);
|
||||
///
|
||||
/// // `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.)
|
||||
///
|
||||
/// *All* origins, methods, request headers and exposed headers allowed. Credentials supported.
|
||||
/// Max age 1 hour. Does not send wildcard.
|
||||
pub fn permissive() -> Self {
|
||||
let inner = Inner {
|
||||
allowed_origins: AllOrSome::All,
|
||||
allowed_origins_fns: smallvec![],
|
||||
|
||||
allowed_methods: ALL_METHODS_SET.clone(),
|
||||
allowed_methods_baked: None,
|
||||
|
||||
allowed_headers: AllOrSome::All,
|
||||
allowed_headers_baked: None,
|
||||
|
||||
expose_headers: AllOrSome::All,
|
||||
expose_headers_baked: None,
|
||||
|
||||
max_age: Some(3600),
|
||||
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 {
|
||||
inner: Rc::new(inner),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets allowed origin list to a state where any origin is accepted.
|
||||
///
|
||||
/// See [`Cors::allowed_origin`] for more info on allowed origins.
|
||||
pub fn allow_any_origin(mut self) -> Cors {
|
||||
if let Some(cors) = cors(&mut self.inner, &self.error) {
|
||||
cors.allowed_origins = AllOrSome::All;
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds 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, no origins are accepted.
|
||||
///
|
||||
/// When this list is set, the client's `Origin` request header will be checked in a
|
||||
/// case-sensitive manner.
|
||||
///
|
||||
/// When all origins are allowed and `send_wildcard` is set, `*` will be sent in the
|
||||
/// `Access-Control-Allow-Origin` response header. If `send_wildcard` is not set, the client's
|
||||
/// `Origin` request header will be echoed back in the `Access-Control-Allow-Origin`
|
||||
/// response header.
|
||||
///
|
||||
/// If the origin of the request doesn't match any allowed origins and at least one
|
||||
/// `allowed_origin_fn` function is set, these functions will be used to determinate
|
||||
/// allowed origins.
|
||||
///
|
||||
/// # Initialization Errors
|
||||
/// - If supplied origin is not valid uri
|
||||
/// - If supplied origin is a wildcard (`*`). [`Cors::send_wildcard`] should be used instead.
|
||||
///
|
||||
/// [Fetch Standard]: https://fetch.spec.whatwg.org/#origin-header
|
||||
pub fn allowed_origin(mut self, origin: &str) -> Cors {
|
||||
if let Some(cors) = cors(&mut self.inner, &self.error) {
|
||||
match TryInto::<Uri>::try_into(origin) {
|
||||
Ok(_) if origin == "*" => {
|
||||
error!("Wildcard in `allowed_origin` is not allowed. Use `send_wildcard`.");
|
||||
self.error = Some(Either::Right(CorsError::WildcardOrigin));
|
||||
}
|
||||
|
||||
Ok(_) => {
|
||||
if cors.allowed_origins.is_all() {
|
||||
cors.allowed_origins = AllOrSome::Some(HashSet::with_capacity(8));
|
||||
}
|
||||
|
||||
if let Some(origins) = cors.allowed_origins.as_mut() {
|
||||
// any uri is a valid header value
|
||||
let hv = origin.try_into().unwrap();
|
||||
origins.insert(hv);
|
||||
}
|
||||
}
|
||||
|
||||
Err(err) => {
|
||||
self.error = Some(Either::Left(err.into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Determinates 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
|
||||
/// each request, which can be used to determine whether to allow the request or not.
|
||||
///
|
||||
/// If the function returns `true`, the client's `Origin` request header will be echoed back
|
||||
/// into the `Access-Control-Allow-Origin` response header.
|
||||
pub fn allowed_origin_fn<F>(mut self, f: F) -> Cors
|
||||
where
|
||||
F: (Fn(&HeaderValue, &RequestHead) -> bool) + 'static,
|
||||
{
|
||||
if let Some(cors) = cors(&mut self.inner, &self.error) {
|
||||
cors.allowed_origins_fns.push(OriginFn {
|
||||
boxed_fn: Rc::new(f),
|
||||
});
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Resets allowed methods list to all methods.
|
||||
///
|
||||
/// 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);
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets a list of methods which allowed origins can perform.
|
||||
///
|
||||
/// These will be sent in the `Access-Control-Allow-Methods` response header.
|
||||
///
|
||||
/// This defaults to an empty set.
|
||||
pub fn allowed_methods<U, M>(mut self, methods: U) -> Cors
|
||||
where
|
||||
U: IntoIterator<Item = M>,
|
||||
M: TryInto<Method>,
|
||||
<M as TryInto<Method>>::Error: Into<HttpError>,
|
||||
{
|
||||
if let Some(cors) = cors(&mut self.inner, &self.error) {
|
||||
for m in methods {
|
||||
match m.try_into() {
|
||||
Ok(method) => {
|
||||
cors.allowed_methods.insert(method);
|
||||
}
|
||||
|
||||
Err(err) => {
|
||||
self.error = Some(Either::Left(err.into()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Resets allowed request header list to a state where any header is accepted.
|
||||
///
|
||||
/// See [`Cors::allowed_headers`] for more info on allowed request headers.
|
||||
pub fn allow_any_header(mut self) -> Cors {
|
||||
if let Some(cors) = cors(&mut self.inner, &self.error) {
|
||||
cors.allowed_headers = AllOrSome::All;
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Add an allowed request header.
|
||||
///
|
||||
/// See [`Cors::allowed_headers`] for more info on allowed request headers.
|
||||
pub fn allowed_header<H>(mut self, header: H) -> Cors
|
||||
where
|
||||
H: TryInto<HeaderName>,
|
||||
<H as TryInto<HeaderName>>::Error: Into<HttpError>,
|
||||
{
|
||||
if let Some(cors) = cors(&mut self.inner, &self.error) {
|
||||
match header.try_into() {
|
||||
Ok(method) => {
|
||||
if cors.allowed_headers.is_all() {
|
||||
cors.allowed_headers = AllOrSome::Some(HashSet::with_capacity(8));
|
||||
}
|
||||
|
||||
if let AllOrSome::Some(ref mut headers) = cors.allowed_headers {
|
||||
headers.insert(method);
|
||||
}
|
||||
}
|
||||
|
||||
Err(err) => self.error = Some(Either::Left(err.into())),
|
||||
}
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets 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.
|
||||
///
|
||||
/// This defaults to an empty set.
|
||||
pub fn allowed_headers<U, H>(mut self, headers: U) -> Cors
|
||||
where
|
||||
U: IntoIterator<Item = H>,
|
||||
H: TryInto<HeaderName>,
|
||||
<H as TryInto<HeaderName>>::Error: Into<HttpError>,
|
||||
{
|
||||
if let Some(cors) = cors(&mut self.inner, &self.error) {
|
||||
for h in headers {
|
||||
match h.try_into() {
|
||||
Ok(method) => {
|
||||
if cors.allowed_headers.is_all() {
|
||||
cors.allowed_headers = AllOrSome::Some(HashSet::with_capacity(8));
|
||||
}
|
||||
|
||||
if let AllOrSome::Some(ref mut headers) = cors.allowed_headers {
|
||||
headers.insert(method);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.error = Some(Either::Left(err.into()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Resets exposed response header list to a state where all headers are exposed.
|
||||
///
|
||||
/// See [`Cors::expose_headers`] for more info on exposed response headers.
|
||||
pub fn expose_any_header(mut self) -> Cors {
|
||||
if let Some(cors) = cors(&mut self.inner, &self.error) {
|
||||
cors.expose_headers = AllOrSome::All;
|
||||
}
|
||||
|
||||
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.
|
||||
///
|
||||
/// This defaults to an empty set.
|
||||
pub fn expose_headers<U, H>(mut self, headers: U) -> Cors
|
||||
where
|
||||
U: IntoIterator<Item = H>,
|
||||
H: TryInto<HeaderName>,
|
||||
<H as TryInto<HeaderName>>::Error: Into<HttpError>,
|
||||
{
|
||||
for h in headers {
|
||||
match h.try_into() {
|
||||
Ok(header) => {
|
||||
if let Some(cors) = cors(&mut self.inner, &self.error) {
|
||||
if cors.expose_headers.is_all() {
|
||||
cors.expose_headers = AllOrSome::Some(HashSet::with_capacity(8));
|
||||
}
|
||||
if let AllOrSome::Some(ref mut headers) = cors.expose_headers {
|
||||
headers.insert(header);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.error = Some(Either::Left(err.into()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
///
|
||||
/// Pass a number (of seconds) or use None to disable sending max age header.
|
||||
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();
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Configures use of wildcard (`*`) origin in responses when appropriate.
|
||||
///
|
||||
/// 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 request’s
|
||||
/// `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.
|
||||
///
|
||||
/// Defaults to disabled.
|
||||
pub fn send_wildcard(mut self) -> Cors {
|
||||
if let Some(cors) = cors(&mut self.inner, &self.error) {
|
||||
cors.send_wildcard = true;
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// 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 {
|
||||
if let Some(cors) = cors(&mut self.inner, &self.error) {
|
||||
cors.allow_private_network_access = true;
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Disables `Vary` header support.
|
||||
///
|
||||
/// When enabled the header `Vary: Origin` will be returned as per the Fetch Standard
|
||||
/// implementation guidelines.
|
||||
///
|
||||
/// Setting this header when the `Access-Control-Allow-Origin` is dynamically generated
|
||||
/// (eg. when there is more than one allowed origin, and an Origin other than '*' is returned)
|
||||
/// informs CDNs and other caches that the CORS headers are dynamic, and cannot be cached.
|
||||
///
|
||||
/// 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;
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Disables preflight request handling.
|
||||
///
|
||||
/// When enabled CORS middleware automatically handles `OPTIONS` requests. This is useful for
|
||||
/// application level middleware.
|
||||
///
|
||||
/// 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;
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Cors {
|
||||
/// A restrictive (security paranoid) set of defaults.
|
||||
///
|
||||
/// *No* allowed origins, methods, request headers or exposed headers. Credentials
|
||||
/// not supported. No max age (will use browser's default).
|
||||
fn default() -> Cors {
|
||||
let inner = Inner {
|
||||
allowed_origins: AllOrSome::Some(HashSet::with_capacity(8)),
|
||||
allowed_origins_fns: smallvec![],
|
||||
|
||||
allowed_methods: HashSet::with_capacity(8),
|
||||
allowed_methods_baked: None,
|
||||
|
||||
allowed_headers: AllOrSome::Some(HashSet::with_capacity(8)),
|
||||
allowed_headers_baked: None,
|
||||
|
||||
expose_headers: AllOrSome::Some(HashSet::with_capacity(8)),
|
||||
expose_headers_baked: None,
|
||||
|
||||
max_age: None,
|
||||
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 {
|
||||
inner: Rc::new(inner),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, B> Transform<S, ServiceRequest> for Cors
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
|
||||
S::Future: 'static,
|
||||
|
||||
B: MessageBody + 'static,
|
||||
{
|
||||
type Response = ServiceResponse<EitherBody<B>>;
|
||||
type Error = Error;
|
||||
type InitError = ();
|
||||
type Transform = CorsMiddleware<S>;
|
||||
type Future = Ready<Result<Self::Transform, Self::InitError>>;
|
||||
|
||||
fn new_transform(&self, service: S) -> Self::Future {
|
||||
if let Some(ref err) = self.error {
|
||||
match err {
|
||||
Either::Left(err) => error!("{}", err),
|
||||
Either::Right(err) => error!("{}", err),
|
||||
}
|
||||
|
||||
return future::err(());
|
||||
}
|
||||
|
||||
let mut inner = Rc::clone(&self.inner);
|
||||
|
||||
if inner.supports_credentials && inner.send_wildcard && inner.allowed_origins.is_all() {
|
||||
error!(
|
||||
"Illegal combination of CORS options: credentials can not be supported when all \
|
||||
origins are allowed and `send_wildcard` is enabled."
|
||||
);
|
||||
return future::err(());
|
||||
}
|
||||
|
||||
// bake allowed headers value if Some and not empty
|
||||
match inner.allowed_headers.as_ref() {
|
||||
Some(header_set) if !header_set.is_empty() => {
|
||||
let allowed_headers_str = intersperse_header_values(header_set);
|
||||
Rc::make_mut(&mut inner).allowed_headers_baked = Some(allowed_headers_str);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// bake allowed methods value if not empty
|
||||
if !inner.allowed_methods.is_empty() {
|
||||
let allowed_methods_str = intersperse_header_values(&inner.allowed_methods);
|
||||
Rc::make_mut(&mut inner).allowed_methods_baked = Some(allowed_methods_str);
|
||||
}
|
||||
|
||||
// bake exposed headers value if Some and not empty
|
||||
match inner.expose_headers.as_ref() {
|
||||
Some(header_set) if !header_set.is_empty() => {
|
||||
let expose_headers_str = intersperse_header_values(header_set);
|
||||
Rc::make_mut(&mut inner).expose_headers_baked = Some(expose_headers_str);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
future::ok(CorsMiddleware { service, inner })
|
||||
}
|
||||
}
|
||||
|
||||
/// Only call when values are guaranteed to be valid header values and set is not empty.
|
||||
pub(crate) fn intersperse_header_values<T>(val_set: &HashSet<T>) -> HeaderValue
|
||||
where
|
||||
T: AsRef<str>,
|
||||
{
|
||||
debug_assert!(
|
||||
!val_set.is_empty(),
|
||||
"only call `intersperse_header_values` when set is not empty"
|
||||
);
|
||||
|
||||
val_set
|
||||
.iter()
|
||||
.fold(String::with_capacity(64), |mut acc, val| {
|
||||
acc.push_str(", ");
|
||||
acc.push_str(val.as_ref());
|
||||
acc
|
||||
})
|
||||
// set is not empty so string will always have leading ", " to trim
|
||||
[2..]
|
||||
.try_into()
|
||||
// all method names are valid header values
|
||||
.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 actix_web::{
|
||||
body,
|
||||
dev::fn_service,
|
||||
http::StatusCode,
|
||||
test::{self, TestRequest},
|
||||
HttpResponse,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn illegal_allow_credentials() {
|
||||
// using the permissive defaults (all origins allowed) and adding send_wildcard
|
||||
// and supports_credentials should error on construction
|
||||
|
||||
assert!(Cors::permissive()
|
||||
.supports_credentials()
|
||||
.send_wildcard()
|
||||
.new_transform(test::ok_service())
|
||||
.into_inner()
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn restrictive_defaults() {
|
||||
let cors = Cors::default()
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = TestRequest::default()
|
||||
.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"));
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn allowed_header_try_from() {
|
||||
let _cors = Cors::default().allowed_header("Content-Type");
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn allowed_header_try_into() {
|
||||
struct ContentType;
|
||||
|
||||
impl TryInto<HeaderName> for ContentType {
|
||||
type Error = Infallible;
|
||||
|
||||
fn try_into(self) -> Result<HeaderName, Self::Error> {
|
||||
Ok(HeaderName::from_static("content-type"))
|
||||
}
|
||||
}
|
||||
|
||||
let _cors = Cors::default().allowed_header(ContentType);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn middleware_generic_over_body_type() {
|
||||
let srv = fn_service(|req: ServiceRequest| async move {
|
||||
Ok(req.into_response(HttpResponse::with_body(StatusCode::OK, body::None::new())))
|
||||
});
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
49
actix-cors/src/error.rs
Normal file
49
actix-cors/src/error.rs
Normal file
@ -0,0 +1,49 @@
|
||||
use actix_web::{http::StatusCode, HttpResponse, ResponseError};
|
||||
use derive_more::derive::{Display, Error};
|
||||
|
||||
/// 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 (`*`)")]
|
||||
WildcardOrigin,
|
||||
|
||||
/// Request header `Origin` is required but was not provided.
|
||||
#[display("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")]
|
||||
MissingRequestMethod,
|
||||
|
||||
/// Request header `Access-Control-Request-Method` has an invalid value.
|
||||
#[display("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")]
|
||||
BadRequestHeaders,
|
||||
|
||||
/// Origin is not allowed to make this request.
|
||||
#[display("Origin is not allowed to make this request")]
|
||||
OriginNotAllowed,
|
||||
|
||||
/// Request method is not allowed.
|
||||
#[display("Requested method is not allowed")]
|
||||
MethodNotAllowed,
|
||||
|
||||
/// One or more request headers are not allowed.
|
||||
#[display("One or more request headers are not allowed")]
|
||||
HeadersNotAllowed,
|
||||
}
|
||||
|
||||
impl ResponseError for CorsError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
HttpResponse::with_body(self.status_code(), self.to_string()).map_into_boxed_body()
|
||||
}
|
||||
}
|
409
actix-cors/src/inner.rs
Normal file
409
actix-cors/src/inner.rs
Normal file
@ -0,0 +1,409 @@
|
||||
use std::{collections::HashSet, fmt, rc::Rc};
|
||||
|
||||
use actix_web::{
|
||||
dev::RequestHead,
|
||||
error::Result,
|
||||
http::{
|
||||
header::{self, HeaderMap, HeaderName, HeaderValue},
|
||||
Method,
|
||||
},
|
||||
};
|
||||
use once_cell::sync::Lazy;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::{AllOrSome, CorsError};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct OriginFn {
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(crate) boxed_fn: Rc<dyn Fn(&HeaderValue, &RequestHead) -> bool>,
|
||||
}
|
||||
|
||||
impl Default for OriginFn {
|
||||
/// Dummy default for use in tiny_vec. Do not use.
|
||||
fn default() -> Self {
|
||||
let boxed_fn: Rc<dyn Fn(&_, &_) -> _> = Rc::new(|_origin, _req_head| false);
|
||||
Self { boxed_fn }
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to parse header value as HTTP method.
|
||||
pub(crate) 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)]
|
||||
pub(crate) struct Inner {
|
||||
pub(crate) allowed_origins: AllOrSome<HashSet<HeaderValue>>,
|
||||
pub(crate) allowed_origins_fns: SmallVec<[OriginFn; 4]>,
|
||||
|
||||
pub(crate) allowed_methods: HashSet<Method>,
|
||||
pub(crate) allowed_methods_baked: Option<HeaderValue>,
|
||||
|
||||
pub(crate) allowed_headers: AllOrSome<HashSet<HeaderName>>,
|
||||
pub(crate) allowed_headers_baked: Option<HeaderValue>,
|
||||
|
||||
/// `All` will echo back `Access-Control-Request-Header` list.
|
||||
pub(crate) expose_headers: AllOrSome<HashSet<HeaderName>>,
|
||||
pub(crate) expose_headers_baked: Option<HeaderValue>,
|
||||
|
||||
pub(crate) max_age: Option<usize>,
|
||||
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> {
|
||||
// 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::Some(allowed_origins) => allowed_origins,
|
||||
// only function origin validators are defined
|
||||
_ => &EMPTY_ORIGIN_SET,
|
||||
};
|
||||
|
||||
// get origin header and try to parse as string
|
||||
match req.headers().get(header::ORIGIN) {
|
||||
// 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)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
// origin header is missing
|
||||
// note: with our implementation, the origin header is required for OPTIONS request or
|
||||
// else this would be unreachable
|
||||
None => Err(CorsError::MissingOrigin),
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts origin if _ANY_ functions return true. Only called when Origin exists.
|
||||
fn validate_origin_fns(&self, origin: &HeaderValue, req: &RequestHead) -> bool {
|
||||
self.allowed_origins_fns
|
||||
.iter()
|
||||
.any(|origin_fn| (origin_fn.boxed_fn)(origin, req))
|
||||
}
|
||||
|
||||
/// Only called if origin exists and always after it's validated.
|
||||
pub(crate) fn access_control_allow_origin(&self, req: &RequestHead) -> Option<HeaderValue> {
|
||||
let origin = req.headers().get(header::ORIGIN);
|
||||
|
||||
match self.allowed_origins {
|
||||
AllOrSome::All => {
|
||||
if self.send_wildcard {
|
||||
Some(HeaderValue::from_static("*"))
|
||||
} else {
|
||||
// see note below about why `.cloned()` is correct
|
||||
origin.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
AllOrSome::Some(_) => {
|
||||
// since origin (if it exists) is known to be allowed if this method is called
|
||||
// then cloning the option is all that is required to be used as an echoed back
|
||||
// header value (or omitted if None)
|
||||
origin.cloned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Use in preflight checks and therefore operates on header list in
|
||||
/// `Access-Control-Request-Headers` not the actual header set.
|
||||
pub(crate) fn validate_allowed_method(&self, req: &RequestHead) -> Result<(), CorsError> {
|
||||
// extract access control header and try to parse as method
|
||||
let request_method = req
|
||||
.headers()
|
||||
.get(header::ACCESS_CONTROL_REQUEST_METHOD)
|
||||
.map(header_value_try_into_method);
|
||||
|
||||
match request_method {
|
||||
// method valid and allowed
|
||||
Some(Some(method)) if self.allowed_methods.contains(&method) => Ok(()),
|
||||
|
||||
// method valid but not allowed
|
||||
Some(Some(_)) => Err(CorsError::MethodNotAllowed),
|
||||
|
||||
// method invalid
|
||||
Some(_) => Err(CorsError::BadRequestMethod),
|
||||
|
||||
// method missing so this is not a preflight request
|
||||
None => Err(CorsError::MissingRequestMethod),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_allowed_headers(&self, req: &RequestHead) -> Result<(), CorsError> {
|
||||
// return early if all headers are allowed or get ref to allowed origins set
|
||||
#[allow(clippy::mutable_key_type)]
|
||||
let allowed_headers = match &self.allowed_headers {
|
||||
AllOrSome::All => return Ok(()),
|
||||
AllOrSome::Some(allowed_headers) => allowed_headers,
|
||||
};
|
||||
|
||||
// extract access control header as string
|
||||
// header format should be comma separated header names
|
||||
let request_headers = req
|
||||
.headers()
|
||||
.get(header::ACCESS_CONTROL_REQUEST_HEADERS)
|
||||
.map(|hdr| hdr.to_str());
|
||||
|
||||
match request_headers {
|
||||
// header list is valid string
|
||||
Some(Ok(headers)) => {
|
||||
// the set is ephemeral we take care not to mutate the
|
||||
// inserted keys so this lint exception is acceptable
|
||||
#[allow(clippy::mutable_key_type)]
|
||||
let mut request_headers = HashSet::with_capacity(8);
|
||||
|
||||
// try to convert each header name in the comma-separated list
|
||||
for hdr in headers.split(',') {
|
||||
match hdr.trim().try_into() {
|
||||
Ok(hdr) => request_headers.insert(hdr),
|
||||
Err(_) => return Err(CorsError::BadRequestHeaders),
|
||||
};
|
||||
}
|
||||
|
||||
// header list must contain 1 or more header name
|
||||
if request_headers.is_empty() {
|
||||
return Err(CorsError::BadRequestHeaders);
|
||||
}
|
||||
|
||||
// request header list must be a subset of allowed headers
|
||||
if !request_headers.is_subset(allowed_headers) {
|
||||
return Err(CorsError::HeadersNotAllowed);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// header list is not a string
|
||||
Some(Err(_)) => Err(CorsError::BadRequestHeaders),
|
||||
|
||||
// header list missing
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add CORS related request headers to response's Vary header.
|
||||
///
|
||||
/// See <https://fetch.spec.whatwg.org/#cors-protocol-and-http-caches>.
|
||||
pub(crate) fn add_vary_header(headers: &mut HeaderMap) {
|
||||
let value = match headers.get(header::VARY) {
|
||||
Some(hdr) => {
|
||||
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",
|
||||
),
|
||||
};
|
||||
|
||||
headers.insert(header::VARY, value);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use std::rc::Rc;
|
||||
|
||||
use actix_web::{
|
||||
dev::Transform,
|
||||
http::{
|
||||
header::{self, HeaderValue},
|
||||
Method, StatusCode,
|
||||
},
|
||||
test::{self, TestRequest},
|
||||
};
|
||||
|
||||
use crate::Cors;
|
||||
|
||||
fn val_as_str(val: &HeaderValue) -> &str {
|
||||
val.to_str().unwrap()
|
||||
}
|
||||
|
||||
#[actix_web::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();
|
||||
|
||||
let req = TestRequest::get()
|
||||
.insert_header((header::ORIGIN, "https://www.unknown.com"))
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_HEADERS, "DNT"))
|
||||
.to_srv_request();
|
||||
|
||||
assert!(cors.inner.validate_origin(req.head()).is_err());
|
||||
assert!(cors.inner.validate_allowed_method(req.head()).is_err());
|
||||
assert!(cors.inner.validate_allowed_headers(req.head()).is_err());
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_preflight() {
|
||||
let mut cors = Cors::default()
|
||||
.allow_any_origin()
|
||||
.send_wildcard()
|
||||
.max_age(3600)
|
||||
.allowed_methods(vec![Method::GET, Method::OPTIONS, Method::POST])
|
||||
.allowed_headers(vec![header::AUTHORIZATION, header::ACCEPT])
|
||||
.allowed_header(header::CONTENT_TYPE)
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = TestRequest::default()
|
||||
.method(Method::OPTIONS)
|
||||
.insert_header(("Origin", "https://www.example.com"))
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_HEADERS, "X-Not-Allowed"))
|
||||
.to_srv_request();
|
||||
|
||||
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);
|
||||
|
||||
let req = TestRequest::default()
|
||||
.method(Method::OPTIONS)
|
||||
.insert_header(("Origin", "https://www.example.com"))
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "put"))
|
||||
.to_srv_request();
|
||||
|
||||
assert!(cors.inner.validate_allowed_method(req.head()).is_err());
|
||||
assert!(cors.inner.validate_allowed_headers(req.head()).is_ok());
|
||||
|
||||
let req = TestRequest::default()
|
||||
.method(Method::OPTIONS)
|
||||
.insert_header(("Origin", "https://www.example.com"))
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
|
||||
.insert_header((
|
||||
header::ACCESS_CONTROL_REQUEST_HEADERS,
|
||||
"AUTHORIZATION,ACCEPT",
|
||||
))
|
||||
.to_srv_request();
|
||||
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
assert_eq!(
|
||||
Some(&b"*"[..]),
|
||||
resp.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.map(HeaderValue::as_bytes)
|
||||
);
|
||||
assert_eq!(
|
||||
Some(&b"3600"[..]),
|
||||
resp.headers()
|
||||
.get(header::ACCESS_CONTROL_MAX_AGE)
|
||||
.map(HeaderValue::as_bytes)
|
||||
);
|
||||
|
||||
let hdr = resp
|
||||
.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_HEADERS)
|
||||
.map(val_as_str)
|
||||
.unwrap();
|
||||
assert!(hdr.contains("authorization"));
|
||||
assert!(hdr.contains("accept"));
|
||||
assert!(hdr.contains("content-type"));
|
||||
|
||||
let methods = resp
|
||||
.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_METHODS)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert!(methods.contains("POST"));
|
||||
assert!(methods.contains("GET"));
|
||||
assert!(methods.contains("OPTIONS"));
|
||||
|
||||
Rc::get_mut(&mut cors.inner).unwrap().preflight = false;
|
||||
|
||||
let req = TestRequest::default()
|
||||
.method(Method::OPTIONS)
|
||||
.insert_header(("Origin", "https://www.example.com"))
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
|
||||
.insert_header((
|
||||
header::ACCESS_CONTROL_REQUEST_HEADERS,
|
||||
"AUTHORIZATION,ACCEPT",
|
||||
))
|
||||
.to_srv_request();
|
||||
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn allow_fn_origin_equals_head_origin() {
|
||||
let cors = Cors::default()
|
||||
.allowed_origin_fn(|origin, head| {
|
||||
let head_origin = head
|
||||
.headers()
|
||||
.get(header::ORIGIN)
|
||||
.expect("unwrapping origin header should never fail in allowed_origin_fn");
|
||||
assert!(origin == head_origin);
|
||||
true
|
||||
})
|
||||
.allow_any_method()
|
||||
.allow_any_header()
|
||||
.new_transform(test::status_service(StatusCode::NO_CONTENT))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = TestRequest::default()
|
||||
.method(Method::OPTIONS)
|
||||
.insert_header(("Origin", "https://www.example.com"))
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
|
||||
.to_srv_request();
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let req = TestRequest::default()
|
||||
.method(Method::GET)
|
||||
.insert_header(("Origin", "https://www.example.com"))
|
||||
.to_srv_request();
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
300
actix-cors/src/middleware.rs
Normal file
300
actix-cors/src/middleware.rs
Normal file
@ -0,0 +1,300 @@
|
||||
use std::{collections::HashSet, rc::Rc};
|
||||
|
||||
use actix_utils::future::ok;
|
||||
use actix_web::{
|
||||
body::{EitherBody, MessageBody},
|
||||
dev::{forward_ready, Service, ServiceRequest, ServiceResponse},
|
||||
http::{
|
||||
header::{self, HeaderValue},
|
||||
Method,
|
||||
},
|
||||
Error, HttpResponse, Result,
|
||||
};
|
||||
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,
|
||||
};
|
||||
|
||||
/// Service wrapper for Cross-Origin Resource Sharing support.
|
||||
///
|
||||
/// This struct contains the settings for CORS requests to be validated and for responses to
|
||||
/// be generated.
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CorsMiddleware<S> {
|
||||
pub(crate) service: S,
|
||||
pub(crate) inner: Rc<Inner>,
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
|
||||
if let Err(err) = inner
|
||||
.validate_allowed_method(req.head())
|
||||
.and_then(|_| inner.validate_allowed_headers(req.head()))
|
||||
{
|
||||
return req.error_response(err);
|
||||
}
|
||||
|
||||
let mut res = HttpResponse::Ok();
|
||||
|
||||
if let Some(origin) = inner.access_control_allow_origin(req.head()) {
|
||||
res.insert_header((header::ACCESS_CONTROL_ALLOW_ORIGIN, origin));
|
||||
}
|
||||
|
||||
if let Some(ref allowed_methods) = inner.allowed_methods_baked {
|
||||
res.insert_header((
|
||||
header::ACCESS_CONTROL_ALLOW_METHODS,
|
||||
allowed_methods.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(ref headers) = inner.allowed_headers_baked {
|
||||
res.insert_header((header::ACCESS_CONTROL_ALLOW_HEADERS, headers.clone()));
|
||||
} else if let Some(headers) = req.headers().get(header::ACCESS_CONTROL_REQUEST_HEADERS) {
|
||||
// all headers allowed, return
|
||||
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,
|
||||
HeaderValue::from_static("true"),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(max_age) = inner.max_age {
|
||||
res.insert_header((header::ACCESS_CONTROL_MAX_AGE, max_age.to_string()));
|
||||
}
|
||||
|
||||
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 {
|
||||
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);
|
||||
|
||||
res.headers_mut()
|
||||
.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() {
|
||||
// extract header names from request
|
||||
let expose_all_request_headers = res
|
||||
.headers()
|
||||
.keys()
|
||||
.map(|name| name.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
// create comma separated string of header names
|
||||
let expose_headers_value = intersperse_header_values(&expose_all_request_headers);
|
||||
|
||||
log::trace!(
|
||||
"exposing all headers from request: {:?}",
|
||||
expose_headers_value
|
||||
);
|
||||
|
||||
// add header names to expose response header
|
||||
res.headers_mut()
|
||||
.insert(header::ACCESS_CONTROL_EXPOSE_HEADERS, expose_headers_value);
|
||||
}
|
||||
}
|
||||
|
||||
if inner.supports_credentials {
|
||||
res.headers_mut().insert(
|
||||
header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
|
||||
HeaderValue::from_static("true"),
|
||||
);
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, B> Service<ServiceRequest> for CorsMiddleware<S>
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
|
||||
S::Future: 'static,
|
||||
|
||||
B: MessageBody + 'static,
|
||||
{
|
||||
type Response = ServiceResponse<EitherBody<B>>;
|
||||
type Error = Error;
|
||||
type Future = LocalBoxFuture<'static, Result<ServiceResponse<EitherBody<B>>, Error>>;
|
||||
|
||||
forward_ready!(service);
|
||||
|
||||
fn call(&self, req: ServiceRequest) -> Self::Future {
|
||||
let origin = req.headers().get(header::ORIGIN);
|
||||
|
||||
// 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)) => {
|
||||
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(res.map_into_right_body()).boxed_local();
|
||||
}
|
||||
};
|
||||
|
||||
let inner = Rc::clone(&self.inner);
|
||||
let fut = self.service.call(req);
|
||||
|
||||
Box::pin(async move {
|
||||
let res = fut.await;
|
||||
Ok(Self::augment_response(&inner, origin_allowed, res?).map_into_left_body())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::{
|
||||
dev::Transform,
|
||||
middleware::Compat,
|
||||
test::{self, TestRequest},
|
||||
App,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::Cors;
|
||||
|
||||
#[test]
|
||||
fn compat_compat() {
|
||||
let _ = App::new().wrap(Compat::new(Cors::default()));
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_options_no_origin() {
|
||||
// Tests case where allowed_origins is All but there are validate functions to run incase.
|
||||
// In this case, origins are only allowed when the DNT header is sent.
|
||||
|
||||
let cors = Cors::default()
|
||||
.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())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = TestRequest::get()
|
||||
.insert_header((header::ORIGIN, "http://example.com"))
|
||||
.to_srv_request();
|
||||
let res = cors.call(req).await.unwrap();
|
||||
assert_eq!(
|
||||
None,
|
||||
res.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.map(HeaderValue::as_bytes)
|
||||
);
|
||||
|
||||
let req = TestRequest::get()
|
||||
.insert_header((header::ORIGIN, "http://example.com"))
|
||||
.insert_header((header::DNT, "1"))
|
||||
.to_srv_request();
|
||||
let res = cors.call(req).await.unwrap();
|
||||
assert_eq!(
|
||||
Some(&b"http://example.com"[..]),
|
||||
res.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.map(HeaderValue::as_bytes)
|
||||
);
|
||||
}
|
||||
}
|
682
actix-cors/tests/tests.rs
Normal file
682
actix-cors/tests/tests.rs
Normal file
@ -0,0 +1,682 @@
|
||||
use actix_cors::Cors;
|
||||
use actix_utils::future::ok;
|
||||
use actix_web::{
|
||||
dev::{fn_service, ServiceRequest, Transform},
|
||||
http::{
|
||||
header::{self, HeaderValue},
|
||||
Method, StatusCode,
|
||||
},
|
||||
test::{self, TestRequest},
|
||||
HttpResponse,
|
||||
};
|
||||
use regex::bytes::Regex;
|
||||
|
||||
fn val_as_str(val: &HeaderValue) -> &str {
|
||||
val.to_str().unwrap()
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[should_panic]
|
||||
async fn test_wildcard_origin() {
|
||||
Cors::default()
|
||||
.allowed_origin("*")
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_not_allowed_origin_fn() {
|
||||
let cors = Cors::default()
|
||||
.allowed_origin("https://www.example.com")
|
||||
.allowed_origin_fn(|origin, req| {
|
||||
assert_eq!(&origin, req.headers.get(header::ORIGIN).unwrap());
|
||||
|
||||
req.headers
|
||||
.get(header::ORIGIN)
|
||||
.map(HeaderValue::as_bytes)
|
||||
.filter(|b| b.ends_with(b".unknown.com"))
|
||||
.is_some()
|
||||
})
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
{
|
||||
let req = TestRequest::get()
|
||||
.insert_header(("Origin", "https://www.example.com"))
|
||||
.to_srv_request();
|
||||
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
|
||||
assert_eq!(
|
||||
Some(&b"https://www.example.com"[..]),
|
||||
resp.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.map(HeaderValue::as_bytes)
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let req = TestRequest::get()
|
||||
.insert_header(("Origin", "https://www.known.com"))
|
||||
.to_srv_request();
|
||||
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
|
||||
assert_eq!(
|
||||
None,
|
||||
resp.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_allowed_origin_fn() {
|
||||
let cors = Cors::default()
|
||||
.allowed_origin("https://www.example.com")
|
||||
.allowed_origin_fn(|origin, req| {
|
||||
assert_eq!(&origin, req.headers.get(header::ORIGIN).unwrap());
|
||||
|
||||
req.headers
|
||||
.get(header::ORIGIN)
|
||||
.map(HeaderValue::as_bytes)
|
||||
.filter(|b| b.ends_with(b".unknown.com"))
|
||||
.is_some()
|
||||
})
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = TestRequest::get()
|
||||
.insert_header(("Origin", "https://www.example.com"))
|
||||
.to_srv_request();
|
||||
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
|
||||
assert_eq!(
|
||||
"https://www.example.com",
|
||||
resp.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.map(val_as_str)
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
let req = TestRequest::get()
|
||||
.insert_header(("Origin", "https://www.unknown.com"))
|
||||
.to_srv_request();
|
||||
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
|
||||
assert_eq!(
|
||||
Some(&b"https://www.unknown.com"[..]),
|
||||
resp.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.map(HeaderValue::as_bytes)
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_allowed_origin_fn_with_environment() {
|
||||
let regex = Regex::new("https:.+\\.unknown\\.com").unwrap();
|
||||
|
||||
let cors = Cors::default()
|
||||
.allowed_origin("https://www.example.com")
|
||||
.allowed_origin_fn(move |origin, req| {
|
||||
assert_eq!(&origin, req.headers.get(header::ORIGIN).unwrap());
|
||||
|
||||
req.headers
|
||||
.get(header::ORIGIN)
|
||||
.map(HeaderValue::as_bytes)
|
||||
.filter(|b| regex.is_match(b))
|
||||
.is_some()
|
||||
})
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = TestRequest::get()
|
||||
.insert_header(("Origin", "https://www.example.com"))
|
||||
.to_srv_request();
|
||||
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
|
||||
assert_eq!(
|
||||
"https://www.example.com",
|
||||
resp.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.map(val_as_str)
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
let req = TestRequest::get()
|
||||
.insert_header(("Origin", "https://www.unknown.com"))
|
||||
.to_srv_request();
|
||||
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
|
||||
assert_eq!(
|
||||
Some(&b"https://www.unknown.com"[..]),
|
||||
resp.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.map(HeaderValue::as_bytes)
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_multiple_origins_preflight() {
|
||||
let cors = Cors::default()
|
||||
.allowed_origin("https://example.com")
|
||||
.allowed_origin("https://example.org")
|
||||
.allowed_methods(vec![Method::GET])
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = TestRequest::default()
|
||||
.insert_header(("Origin", "https://example.com"))
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "GET"))
|
||||
.method(Method::OPTIONS)
|
||||
.to_srv_request();
|
||||
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
assert_eq!(
|
||||
Some(&b"https://example.com"[..]),
|
||||
resp.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.map(HeaderValue::as_bytes)
|
||||
);
|
||||
|
||||
let req = TestRequest::default()
|
||||
.insert_header(("Origin", "https://example.org"))
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "GET"))
|
||||
.method(Method::OPTIONS)
|
||||
.to_srv_request();
|
||||
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
assert_eq!(
|
||||
Some(&b"https://example.org"[..]),
|
||||
resp.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.map(HeaderValue::as_bytes)
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_multiple_origins() {
|
||||
let cors = Cors::default()
|
||||
.allowed_origin("https://example.com")
|
||||
.allowed_origin("https://example.org")
|
||||
.allowed_methods(vec![Method::GET])
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = TestRequest::get()
|
||||
.insert_header(("Origin", "https://example.com"))
|
||||
.to_srv_request();
|
||||
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
assert_eq!(
|
||||
Some(&b"https://example.com"[..]),
|
||||
resp.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.map(HeaderValue::as_bytes)
|
||||
);
|
||||
|
||||
let req = TestRequest::get()
|
||||
.insert_header(("Origin", "https://example.org"))
|
||||
.to_srv_request();
|
||||
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
assert_eq!(
|
||||
Some(&b"https://example.org"[..]),
|
||||
resp.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.map(HeaderValue::as_bytes)
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_response() {
|
||||
let exposed_headers = vec![header::AUTHORIZATION, header::ACCEPT];
|
||||
let cors = Cors::default()
|
||||
.allow_any_origin()
|
||||
.send_wildcard()
|
||||
.disable_preflight()
|
||||
.max_age(3600)
|
||||
.allowed_methods(vec![Method::GET, Method::OPTIONS, Method::POST])
|
||||
.allowed_headers(exposed_headers.clone())
|
||||
.expose_headers(exposed_headers.clone())
|
||||
.allowed_header(header::CONTENT_TYPE)
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = TestRequest::default()
|
||||
.insert_header(("Origin", "https://www.example.com"))
|
||||
.method(Method::OPTIONS)
|
||||
.to_srv_request();
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
assert_eq!(
|
||||
Some(&b"*"[..]),
|
||||
resp.headers()
|
||||
.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)]
|
||||
{
|
||||
let headers = resp
|
||||
.headers()
|
||||
.get(header::ACCESS_CONTROL_EXPOSE_HEADERS)
|
||||
.map(val_as_str)
|
||||
.unwrap()
|
||||
.split(',')
|
||||
.map(|s| s.trim())
|
||||
.collect::<Vec<&str>>();
|
||||
|
||||
// TODO: use HashSet subset check
|
||||
for h in exposed_headers {
|
||||
assert!(headers.contains(&h.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
let exposed_headers = vec![header::AUTHORIZATION, header::ACCEPT];
|
||||
let cors = Cors::default()
|
||||
.allow_any_origin()
|
||||
.send_wildcard()
|
||||
.disable_preflight()
|
||||
.max_age(3600)
|
||||
.allowed_methods(vec![Method::GET, Method::OPTIONS, Method::POST])
|
||||
.allowed_headers(exposed_headers.clone())
|
||||
.expose_headers(exposed_headers.clone())
|
||||
.allowed_header(header::CONTENT_TYPE)
|
||||
.new_transform(fn_service(|req: ServiceRequest| {
|
||||
ok(req.into_response({
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::VARY, "Accept"))
|
||||
.finish()
|
||||
}))
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = TestRequest::default()
|
||||
.insert_header(("Origin", "https://www.example.com"))
|
||||
.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",
|
||||
);
|
||||
|
||||
let cors = Cors::default()
|
||||
.disable_vary_header()
|
||||
.allowed_methods(vec!["POST"])
|
||||
.allowed_origin("https://www.example.com")
|
||||
.allowed_origin("https://www.google.com")
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = TestRequest::default()
|
||||
.insert_header(("Origin", "https://www.example.com"))
|
||||
.method(Method::OPTIONS)
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
|
||||
.to_srv_request();
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
let origins_str = resp
|
||||
.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.map(val_as_str);
|
||||
assert_eq!(Some("https://www.example.com"), origins_str);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_validate_origin() {
|
||||
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.com"))
|
||||
.to_srv_request();
|
||||
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
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()
|
||||
.disable_preflight()
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = TestRequest::default().method(Method::GET).to_srv_request();
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
assert!(resp
|
||||
.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.is_none());
|
||||
|
||||
let req = TestRequest::default()
|
||||
.insert_header(("Origin", "https://www.example.com"))
|
||||
.method(Method::OPTIONS)
|
||||
.to_srv_request();
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
assert_eq!(
|
||||
Some(&b"https://www.example.com"[..]),
|
||||
resp.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.map(HeaderValue::as_bytes)
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn validate_origin_allows_all_origins() {
|
||||
let cors = Cors::permissive()
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = TestRequest::default()
|
||||
.insert_header(("Origin", "https://www.example.com"))
|
||||
.to_srv_request();
|
||||
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn vary_header_on_all_handled_responses() {
|
||||
let cors = Cors::permissive()
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// preflight request
|
||||
let req = TestRequest::default()
|
||||
.method(Method::OPTIONS)
|
||||
.insert_header((header::ORIGIN, "https://www.example.com"))
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "GET"))
|
||||
.to_srv_request();
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert!(resp
|
||||
.headers()
|
||||
.contains_key(header::ACCESS_CONTROL_ALLOW_METHODS));
|
||||
#[cfg(not(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",
|
||||
);
|
||||
#[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()
|
||||
.method(Method::PUT)
|
||||
.insert_header((header::ORIGIN, "https://www.example.com"))
|
||||
.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)
|
||||
.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!(
|
||||
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()
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// regular request OK with no CORS response headers
|
||||
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"))]
|
||||
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",
|
||||
);
|
||||
#[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)
|
||||
.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!(
|
||||
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]
|
||||
async fn test_allow_any_origin_any_method_any_header() {
|
||||
let cors = Cors::default()
|
||||
.allow_any_origin()
|
||||
.allow_any_method()
|
||||
.allow_any_header()
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = TestRequest::default()
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_HEADERS, "content-type"))
|
||||
.insert_header((header::ORIGIN, "https://www.example.com"))
|
||||
.method(Method::OPTIONS)
|
||||
.to_srv_request();
|
||||
|
||||
let resp = test::call_service(&cors, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[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)
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = TestRequest::default()
|
||||
.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"))
|
||||
.to_srv_request();
|
||||
|
||||
let res = test::call_service(&cors, req).await;
|
||||
|
||||
let cd_hdr = res
|
||||
.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"));
|
||||
}
|
@ -1,23 +1,158 @@
|
||||
# Changes
|
||||
|
||||
## [unreleased]
|
||||
## Unreleased
|
||||
|
||||
* Minimum supported Rust version(MSRV) is now 1.40.0.
|
||||
## 0.8.0
|
||||
|
||||
## [0.3.0-alpha.1] - 2020-03-14
|
||||
- Update `actix-session` dependency to `0.10`.
|
||||
|
||||
* Update the `time` dependency to 0.2.7
|
||||
* Update the `actix-web` dependency to 3.0.0-alpha.1
|
||||
* Minimize `futures` dependency
|
||||
## 0.7.1
|
||||
|
||||
## [0.2.1] - 2020-01-10
|
||||
- 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.
|
||||
|
||||
* Fix panic with already borrowed: BorrowMutError #1263
|
||||
## 0.7.0
|
||||
|
||||
## [0.2.0] - 2019-12-20
|
||||
- Update `actix-session` dependency to `0.9`.
|
||||
- Minimum supported Rust version (MSRV) is now 1.75.
|
||||
|
||||
* Use actix-web 2.0
|
||||
## 0.6.0
|
||||
|
||||
## [0.1.0] - 2019-06-xx
|
||||
- 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.
|
||||
|
||||
* Move identity middleware to separate crate
|
||||
## 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
|
||||
|
||||
- Update `actix-web` dependency to `4`.
|
||||
|
||||
## 0.4.0-beta.9
|
||||
|
||||
- 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
|
||||
|
||||
- No significant changes since `0.4.0-beta.7`.
|
||||
|
||||
## 0.4.0-beta.7
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
- No significant changes since `0.4.0-beta.3`.
|
||||
|
||||
## 0.4.0-beta.3
|
||||
|
||||
- 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
|
||||
|
||||
- No notable changes.
|
||||
|
||||
## 0.4.0-beta.1
|
||||
|
||||
- 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.
|
||||
- Minimum supported Rust version (MSRV) is now 1.46.0.
|
||||
|
||||
[#168]: https://github.com/actix/actix-extras/pull/168
|
||||
|
||||
## 0.3.1
|
||||
|
||||
- Add method to set `HttpOnly` flag on cookie identity. [#102]
|
||||
|
||||
[#102]: https://github.com/actix/actix-extras/pull/102
|
||||
|
||||
## 0.3.0
|
||||
|
||||
- Update `actix-web` dependency to 3.0.0.
|
||||
- Minimum supported Rust version (MSRV) is now 1.42.0.
|
||||
|
||||
## 0.3.0-alpha.1
|
||||
|
||||
- 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
|
||||
|
||||
- Fix panic with already borrowed: BorrowMutError #1263
|
||||
|
||||
## 0.2.0 - 2019-12-20
|
||||
|
||||
- Use actix-web 2.0
|
||||
|
||||
## 0.1.0 - 2019-06-xx
|
||||
|
||||
- Move identity middleware to separate crate
|
||||
|
@ -1,29 +1,41 @@
|
||||
[package]
|
||||
name = "actix-identity"
|
||||
version = "0.3.0-alpha.1"
|
||||
authors = ["Nikolay Kim <fafhrd91@gmail.com>"]
|
||||
description = "Identity service for actix-web framework."
|
||||
readme = "README.md"
|
||||
keywords = ["http", "web", "framework", "async", "futures"]
|
||||
homepage = "https://actix.rs"
|
||||
repository = "https://github.com/actix/actix-web.git"
|
||||
documentation = "https://docs.rs/actix-identity/"
|
||||
license = "MIT/Apache-2.0"
|
||||
edition = "2018"
|
||||
version = "0.8.0"
|
||||
authors = [
|
||||
"Nikolay Kim <fafhrd91@gmail.com>",
|
||||
"Luca Palmieri <rust@lpalmieri.com>",
|
||||
]
|
||||
description = "Identity management 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
|
||||
|
||||
[lib]
|
||||
name = "actix_identity"
|
||||
path = "src/lib.rs"
|
||||
[package.metadata.docs.rs]
|
||||
rustdoc-args = ["--cfg", "docsrs"]
|
||||
all-features = true
|
||||
|
||||
[dependencies]
|
||||
actix-web = { version = "3.0.0-alpha.1", default-features = false, features = ["secure-cookies"] }
|
||||
actix-service = "1.0.2"
|
||||
futures-util = { version = "0.3.4", default-features = false }
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
time = { version = "0.2.7", default-features = false, features = ["std"] }
|
||||
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"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tracing = { version = "0.1.30", default-features = false, features = ["log"] }
|
||||
|
||||
[dev-dependencies]
|
||||
actix-rt = "1.0.0"
|
||||
actix-http = "2.0.0-alpha.2"
|
||||
bytes = "0.5.3"
|
||||
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
|
||||
|
@ -1,19 +1,106 @@
|
||||
# actix-identity
|
||||
|
||||
[](https://crates.io/crates/actix-identity)
|
||||
[](https://docs.rs/actix-identity)
|
||||
[](https://deps.rs/crate/actix-identity/0.2.1)
|
||||
[](https://travis-ci.org/actix/actix-identity)
|
||||
[](https://codecov.io/gh/actix/actix-identity)
|
||||
> Identity management for Actix Web.
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
[](https://crates.io/crates/actix-identity)
|
||||
[](https://docs.rs/actix-identity/0.8.0)
|
||||

|
||||
[](https://gitter.im/actix/actix?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||
[](https://deps.rs/crate/actix-identity/0.8.0)
|
||||
|
||||
> Identity service for actix-web framework.
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
## Documentation & community resources
|
||||
<!-- cargo-rdme start -->
|
||||
|
||||
* [User Guide](https://actix.rs/docs/)
|
||||
* [API Documentation](https://docs.rs/actix-identity/)
|
||||
* [Chat on gitter](https://gitter.im/actix/actix)
|
||||
* Cargo package: [actix-identity](https://crates.io/crates/actix-identity)
|
||||
* Minimum supported Rust version: 1.40 or later
|
||||
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 -->
|
||||
|
93
actix-identity/examples/identity.rs
Normal file
93
actix-identity/examples/identity.rs
Normal file
@ -0,0 +1,93 @@
|
||||
//! 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()
|
||||
}
|
125
actix-identity/src/config.rs
Normal file
125
actix-identity/src/config.rs
Normal file
@ -0,0 +1,125 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
70
actix-identity/src/error.rs
Normal file
70
actix-identity/src/error.rs
Normal file
@ -0,0 +1,70 @@
|
||||
//! 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,
|
||||
}
|
||||
}
|
||||
}
|
272
actix-identity/src/identity.rs
Normal file
272
actix-identity/src/identity.rs
Normal file
@ -0,0 +1,272 @@
|
||||
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,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::LogoutBehaviour,
|
||||
error::{
|
||||
GetIdentityError, LoginError, LostIdentityError, MissingIdentityError, SessionExpiryError,
|
||||
},
|
||||
};
|
||||
|
||||
/// A verified user identity. It can be used as a request extractor.
|
||||
///
|
||||
/// The lifecycle of a user identity is tied to the lifecycle of the underlying session. If the
|
||||
/// session is destroyed (e.g. the session expired), the user identity will be forgotten, de-facto
|
||||
/// forcing a user log out.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use actix_web::{
|
||||
/// get, post, Responder, HttpRequest, HttpMessage, HttpResponse
|
||||
/// };
|
||||
/// 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()
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// #[post("/login")]
|
||||
/// async fn login(request: HttpRequest) -> impl Responder {
|
||||
/// Identity::login(&request.extensions(), "User1".into());
|
||||
/// HttpResponse::Ok()
|
||||
/// }
|
||||
///
|
||||
/// #[post("/logout")]
|
||||
/// async fn logout(user: Identity) -> impl Responder {
|
||||
/// user.logout();
|
||||
/// 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())
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn extract(ext: &Extensions) -> Result<Self, GetIdentityError> {
|
||||
let inner = IdentityInner::extract(ext);
|
||||
inner.get_identity()?;
|
||||
Ok(Self(inner))
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extractor implementation for [`Identity`].
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use actix_web::{get, Responder};
|
||||
/// use actix_identity::Identity;
|
||||
///
|
||||
/// #[get("/")]
|
||||
/// async fn index(user: Option<Identity>) -> impl Responder {
|
||||
/// if let Some(user) = user {
|
||||
/// format!("Welcome! {}", user.id().unwrap())
|
||||
/// } else {
|
||||
/// "Welcome Anonymous!".to_owned()
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
impl FromRequest for Identity {
|
||||
type Error = Error;
|
||||
type Future = Ready<Result<Self, Self::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)
|
||||
}))
|
||||
}
|
||||
}
|
27
actix-identity/src/identity_ext.rs
Normal file
27
actix-identity/src/identity_ext.rs
Normal file
@ -0,0 +1,27 @@
|
||||
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())
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
259
actix-identity/src/middleware.rs
Normal file
259
actix-identity/src/middleware.rs
Normal file
@ -0,0 +1,259 @@
|
||||
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},
|
||||
dev::{Service, ServiceRequest, ServiceResponse, Transform},
|
||||
Error, HttpMessage as _, Result,
|
||||
};
|
||||
use futures_core::future::LocalBoxFuture;
|
||||
|
||||
use crate::{
|
||||
config::{Configuration, IdentityMiddlewareBuilder},
|
||||
identity::IdentityInner,
|
||||
Identity,
|
||||
};
|
||||
|
||||
/// Identity management middleware.
|
||||
///
|
||||
/// ```no_run
|
||||
/// use actix_web::{cookie::Key, App, HttpServer};
|
||||
/// use actix_session::storage::RedisSessionStore;
|
||||
/// use actix_identity::{Identity, IdentityMiddleware};
|
||||
/// use actix_session::{Session, SessionMiddleware};
|
||||
///
|
||||
/// #[actix_web::main]
|
||||
/// async fn main() {
|
||||
/// let secret_key = Key::generate();
|
||||
/// let redis_store = RedisSessionStore::new("redis://127.0.0.1:6379").await.unwrap();
|
||||
///
|
||||
/// HttpServer::new(move || {
|
||||
/// App::new()
|
||||
/// // Install the identity framework first.
|
||||
/// .wrap(IdentityMiddleware::default())
|
||||
/// // The identity system is built on top of sessions.
|
||||
/// // You must install the session middleware to leverage `actix-identity`.
|
||||
/// .wrap(SessionMiddleware::new(redis_store.clone(), secret_key.clone()))
|
||||
/// })
|
||||
/// # ;
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Default, Clone)]
|
||||
pub struct IdentityMiddleware {
|
||||
configuration: Rc<Configuration>,
|
||||
}
|
||||
|
||||
impl IdentityMiddleware {
|
||||
pub(crate) fn new(configuration: Configuration) -> Self {
|
||||
Self {
|
||||
configuration: Rc::new(configuration),
|
||||
}
|
||||
}
|
||||
|
||||
/// A fluent API to configure [`IdentityMiddleware`].
|
||||
pub fn builder() -> IdentityMiddlewareBuilder {
|
||||
IdentityMiddlewareBuilder::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, B> Transform<S, ServiceRequest> for IdentityMiddleware
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
|
||||
S::Future: 'static,
|
||||
B: MessageBody + 'static,
|
||||
{
|
||||
type Response = ServiceResponse<B>;
|
||||
type Error = Error;
|
||||
type Transform = InnerIdentityMiddleware<S>;
|
||||
type InitError = ();
|
||||
type Future = Ready<Result<Self::Transform, Self::InitError>>;
|
||||
|
||||
fn new_transform(&self, service: S) -> Self::Future {
|
||||
ready(Ok(InnerIdentityMiddleware {
|
||||
service: Rc::new(service),
|
||||
configuration: Rc::clone(&self.configuration),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub struct InnerIdentityMiddleware<S> {
|
||||
service: Rc<S>,
|
||||
configuration: Rc<Configuration>,
|
||||
}
|
||||
|
||||
impl<S> Clone for InnerIdentityMiddleware<S> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
service: Rc::clone(&self.service),
|
||||
configuration: Rc::clone(&self.configuration),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, B> Service<ServiceRequest> for InnerIdentityMiddleware<S>
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
|
||||
S::Future: 'static,
|
||||
B: MessageBody + '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, 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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
if !must_extract_identity {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(login_deadline) = configuration.login_deadline {
|
||||
if matches!(
|
||||
enforce_login_deadline(&identity, login_deadline),
|
||||
PolicyDecision::LogOut
|
||||
) {
|
||||
identity.logout();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
17
actix-identity/tests/integration/fixtures.rs
Normal file
17
actix-identity/tests/integration/fixtures.rs
Normal file
@ -0,0 +1,17 @@
|
||||
use actix_session::{storage::CookieSessionStore, SessionMiddleware};
|
||||
use actix_web::cookie::Key;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn store() -> CookieSessionStore {
|
||||
CookieSessionStore::default()
|
||||
}
|
||||
|
||||
pub fn user_id() -> String {
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
pub fn session_middleware() -> SessionMiddleware<CookieSessionStore> {
|
||||
SessionMiddleware::builder(store(), Key::generate())
|
||||
.cookie_domain(Some("localhost".into()))
|
||||
.build()
|
||||
}
|
212
actix-identity/tests/integration/integration.rs
Normal file
212
actix-identity/tests/integration/integration.rs
Normal file
@ -0,0 +1,212 @@
|
||||
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);
|
||||
}
|
3
actix-identity/tests/integration/main.rs
Normal file
3
actix-identity/tests/integration/main.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod fixtures;
|
||||
mod integration;
|
||||
pub mod test_app;
|
187
actix-identity/tests/integration/test_app.rs
Normal file
187
actix-identity/tests/integration/test_app.rs
Normal file
@ -0,0 +1,187 @@
|
||||
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()
|
||||
}
|
41
actix-limitation/CHANGES.md
Normal file
41
actix-limitation/CHANGES.md
Normal file
@ -0,0 +1,41 @@
|
||||
# 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>.
|
43
actix-limitation/Cargo.toml
Normal file
43
actix-limitation/Cargo.toml
Normal file
@ -0,0 +1,43 @@
|
||||
[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
|
58
actix-limitation/README.md
Normal file
58
actix-limitation/README.md
Normal file
@ -0,0 +1,58 @@
|
||||
# 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 -->
|
||||
|
||||
[](https://crates.io/crates/actix-limitation)
|
||||
[](https://docs.rs/actix-limitation/0.5.1)
|
||||

|
||||
[](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
|
||||
}
|
||||
```
|
171
actix-limitation/src/builder.rs
Normal file
171
actix-limitation/src/builder.rs
Normal file
@ -0,0 +1,171 @@
|
||||
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();
|
||||
}
|
||||
}
|
42
actix-limitation/src/errors.rs
Normal file
42
actix-limitation/src/errors.rs
Normal file
@ -0,0 +1,42 @@
|
||||
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>,
|
||||
}
|
||||
}
|
179
actix-limitation/src/lib.rs
Normal file
179
actix-limitation/src/lib.rs
Normal file
@ -0,0 +1,179 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
115
actix-limitation/src/middleware.rs
Normal file
115
actix-limitation/src/middleware.rs
Normal file
@ -0,0 +1,115 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
118
actix-limitation/src/status.rs
Normal file
118
actix-limitation/src/status.rs
Normal file
@ -0,0 +1,118 @@
|
||||
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();
|
||||
}
|
||||
}
|
92
actix-limitation/tests/tests.rs
Normal file
92
actix-limitation/tests/tests.rs
Normal file
@ -0,0 +1,92 @@
|
||||
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(())
|
||||
}
|
@ -1,36 +1,101 @@
|
||||
# Changes
|
||||
|
||||
## unreleased (for alpha version)
|
||||
## Unreleased
|
||||
|
||||
* Minimum supported Rust version(MSRV) is now 1.40.0.
|
||||
## 0.11.0
|
||||
|
||||
## 0.5.1 (2019-02-17)
|
||||
- Updated `prost` dependency to `0.13`.
|
||||
- Minimum supported Rust version (MSRV) is now 1.75.
|
||||
|
||||
* Move repository to actix-extras
|
||||
## 0.10.0
|
||||
|
||||
## 0.5.0 (2019-01-24)
|
||||
- Updated `prost` dependency to `0.12`.
|
||||
- Minimum supported Rust version (MSRV) is now 1.68.
|
||||
|
||||
* Migrate to actix-web 2.0.0 and std::future
|
||||
* Update prost to 0.6
|
||||
* Update bytes to 0.5
|
||||
## 0.9.0
|
||||
|
||||
## 0.4.1 (2019-10-03)
|
||||
- Added `application/x-protobuf` as an acceptable header.
|
||||
- Updated `prost` dependency to `0.11`.
|
||||
|
||||
* Upgrade prost and prost-derive to 0.5.0
|
||||
## 0.8.0
|
||||
|
||||
## 0.4.0 (2019-05-18)
|
||||
- Update `prost` dependency to `0.10`.
|
||||
- Minimum supported Rust version (MSRV) is now 1.57 due to transitive `time` dependency.
|
||||
|
||||
* Upgrade to actix-web 1.0.0-rc
|
||||
* Removed `protobuf` method for `HttpRequest` (use `ProtoBuf` extractor instead)
|
||||
## 0.7.0
|
||||
|
||||
## 0.3.0 (2019-03-07)
|
||||
- Update `actix-web` dependency to `4`.
|
||||
|
||||
* Upgrade to actix-web 0.7.18
|
||||
## 0.7.0-beta.5
|
||||
|
||||
## 0.2.0 (2018-04-10)
|
||||
- Update `prost` dependency to `0.9`.
|
||||
- Update `actix-web` dependency to `4.0.0-rc.1`.
|
||||
|
||||
* Provide protobuf extractor
|
||||
## 0.7.0-beta.4
|
||||
|
||||
## 0.1.0 (2018-03-21)
|
||||
- Minimum supported Rust version (MSRV) is now 1.54.
|
||||
|
||||
* First release
|
||||
## 0.7.0-beta.3
|
||||
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
|
||||
[#197]: https://github.com/actix/actix-extras/pull/197
|
||||
[#203]: https://github.com/actix/actix-extras/pull/203
|
||||
|
||||
## 0.7.0-beta.1
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
- Move repository to actix-extras
|
||||
|
||||
## 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
|
||||
|
||||
- Upgrade prost and prost-derive to 0.5.0
|
||||
|
||||
## 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
|
||||
|
||||
- Upgrade to actix-web 0.7.18
|
||||
|
||||
## 0.2.0 - 2018-04-10
|
||||
|
||||
- Provide protobuf extractor
|
||||
|
||||
## 0.1.0 - 2018-03-21
|
||||
|
||||
- First release
|
||||
|
@ -1,29 +1,31 @@
|
||||
[package]
|
||||
name = "actix-protobuf"
|
||||
version = "0.5.1"
|
||||
edition = "2018"
|
||||
authors = ["kingxsp <jin.hb.zh@outlook.com>", "Yuki Okushi <huyuumi.dev@gmail.com>"]
|
||||
description = "Protobuf support for actix-web framework."
|
||||
readme = "README.md"
|
||||
keywords = ["actix"]
|
||||
homepage = "https://github.com/actix/actix-extras"
|
||||
repository = "https://github.com/actix/actix-extras.git"
|
||||
license = "MIT/Apache-2.0"
|
||||
exclude = [".cargo/config", "/examples/**"]
|
||||
version = "0.11.0"
|
||||
authors = [
|
||||
"kingxsp <jin.hb.zh@outlook.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
|
||||
|
||||
[lib]
|
||||
name = "actix_protobuf"
|
||||
path = "src/lib.rs"
|
||||
[package.metadata.docs.rs]
|
||||
rustdoc-args = ["--cfg", "docsrs"]
|
||||
all-features = true
|
||||
|
||||
[dependencies]
|
||||
bytes = "0.5"
|
||||
futures = "0.3.1"
|
||||
derive_more = "0.99"
|
||||
|
||||
actix-rt = "1"
|
||||
actix-web = "2"
|
||||
|
||||
prost = "0.6.0"
|
||||
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 }
|
||||
|
||||
[dev-dependencies]
|
||||
prost-derive = "0.6.0"
|
||||
actix-web = { version = "4", default-features = false, features = ["macros"] }
|
||||
prost = { version = "0.13", default-features = false, features = ["prost-derive"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
@ -1,14 +1,21 @@
|
||||
# actix-protobuf
|
||||
|
||||
[](https://crates.io/crates/actix-protobuf)
|
||||
[](https://docs.rs/actix-protobuf)
|
||||
[](https://deps.rs/crate/actix-protobuf/0.5.1)
|
||||
> Protobuf payload extractor for Actix Web.
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
[](https://crates.io/crates/actix-protobuf)
|
||||
[](https://docs.rs/actix-protobuf/0.11.0)
|
||||

|
||||
[](https://gitter.im/actix/actix?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||
[](https://deps.rs/crate/actix-protobuf/0.11.0)
|
||||
|
||||
> Protobuf support for actix-web framework.
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
* Minimum supported Rust version: 1.40.0 or later
|
||||
## 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
|
||||
|
||||
## Example
|
||||
|
||||
@ -20,6 +27,7 @@ use actix_web::*;
|
||||
pub struct MyObj {
|
||||
#[prost(int32, tag = "1")]
|
||||
pub number: i32,
|
||||
|
||||
#[prost(string, tag = "2")]
|
||||
pub name: String,
|
||||
}
|
||||
@ -30,13 +38,13 @@ async fn index(msg: ProtoBuf<MyObj>) -> Result<HttpResponse> {
|
||||
}
|
||||
```
|
||||
|
||||
See [here](https://github.com/actix/actix-protobuf/tree/master/examples/prost-example) for the complete example.
|
||||
See [here](https://github.com/actix/examples/tree/master/protobuf) for the complete example.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under either of
|
||||
|
||||
* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or [https://www.apache.org/licenses/LICENSE-2.0](https://www.apache.org/licenses/LICENSE-2.0))
|
||||
* MIT license ([LICENSE-MIT](LICENSE-MIT) or [https://opensource.org/licenses/MIT](https://opensource.org/licenses/MIT))
|
||||
- Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or [https://www.apache.org/licenses/LICENSE-2.0](https://www.apache.org/licenses/LICENSE-2.0))
|
||||
- MIT license ([LICENSE-MIT](LICENSE-MIT) or [https://opensource.org/licenses/MIT](https://opensource.org/licenses/MIT))
|
||||
|
||||
at your option.
|
||||
|
@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "prost-example"
|
||||
version = "0.5.1"
|
||||
edition = "2018"
|
||||
authors = ["kingxsp <jin.hb.zh@outlook.com>", "Yuki Okushi <huyuumi.dev@gmail.com>"]
|
||||
|
||||
[dependencies]
|
||||
bytes = "0.5"
|
||||
env_logger = "*"
|
||||
|
||||
prost = "0.6.0"
|
||||
prost-derive = "0.6.0"
|
||||
|
||||
actix-rt = "1"
|
||||
actix-web = "2"
|
||||
actix-protobuf = { path="../../" }
|
@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# just start server and run client.py
|
||||
|
||||
# wget https://github.com/protocolbuffers/protobuf/releases/download/v3.11.2/protobuf-python-3.11.2.zip
|
||||
# unzip protobuf-python-3.11.2.zip
|
||||
# cd protobuf-3.11.2/python/
|
||||
# python3 setup.py install
|
||||
|
||||
# pip3 install --upgrade pip
|
||||
# pip3 install aiohttp
|
||||
|
||||
# python3 client.py
|
||||
|
||||
import test_pb2
|
||||
import traceback
|
||||
import sys
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
|
||||
def op():
|
||||
try:
|
||||
obj = test_pb2.MyObj()
|
||||
obj.number = 9
|
||||
obj.name = 'USB'
|
||||
|
||||
#Serialize
|
||||
sendDataStr = obj.SerializeToString()
|
||||
#print serialized string value
|
||||
print('serialized string:', sendDataStr)
|
||||
#------------------------#
|
||||
# message transmission #
|
||||
#------------------------#
|
||||
receiveDataStr = sendDataStr
|
||||
receiveData = test_pb2.MyObj()
|
||||
|
||||
#Deserialize
|
||||
receiveData.ParseFromString(receiveDataStr)
|
||||
print('pares serialize string, return: devId = ', receiveData.number, ', name = ', receiveData.name)
|
||||
except(Exception, e):
|
||||
print(Exception, ':', e)
|
||||
print(traceback.print_exc())
|
||||
errInfo = sys.exc_info()
|
||||
print(errInfo[0], ':', errInfo[1])
|
||||
|
||||
|
||||
async def fetch(session):
|
||||
obj = test_pb2.MyObj()
|
||||
obj.number = 9
|
||||
obj.name = 'USB'
|
||||
async with session.post('http://127.0.0.1:8081/', data=obj.SerializeToString(),
|
||||
headers={"content-type": "application/protobuf"}) as resp:
|
||||
print(resp.status)
|
||||
data = await resp.read()
|
||||
receiveObj = test_pb2.MyObj()
|
||||
receiveObj.ParseFromString(data)
|
||||
print(receiveObj)
|
||||
|
||||
async def go(loop):
|
||||
obj = test_pb2.MyObj()
|
||||
obj.number = 9
|
||||
obj.name = 'USB'
|
||||
async with aiohttp.ClientSession(loop=loop) as session:
|
||||
await fetch(session)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(go(loop))
|
||||
loop.close()
|
@ -1,34 +0,0 @@
|
||||
#[macro_use]
|
||||
extern crate prost_derive;
|
||||
|
||||
use actix_protobuf::*;
|
||||
use actix_web::*;
|
||||
|
||||
#[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_rt::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
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
message MyObj {
|
||||
int32 number = 1;
|
||||
string name = 2;
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: test.proto
|
||||
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from google.protobuf import reflection as _reflection
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor.FileDescriptor(
|
||||
name='test.proto',
|
||||
package='',
|
||||
syntax='proto3',
|
||||
serialized_options=None,
|
||||
serialized_pb=b'\n\ntest.proto\"%\n\x05MyObj\x12\x0e\n\x06number\x18\x01 \x01(\x05\x12\x0c\n\x04name\x18\x02 \x01(\tb\x06proto3'
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
_MYOBJ = _descriptor.Descriptor(
|
||||
name='MyObj',
|
||||
full_name='MyObj',
|
||||
filename=None,
|
||||
file=DESCRIPTOR,
|
||||
containing_type=None,
|
||||
fields=[
|
||||
_descriptor.FieldDescriptor(
|
||||
name='number', full_name='MyObj.number', index=0,
|
||||
number=1, type=5, cpp_type=1, label=1,
|
||||
has_default_value=False, default_value=0,
|
||||
message_type=None, enum_type=None, containing_type=None,
|
||||
is_extension=False, extension_scope=None,
|
||||
serialized_options=None, file=DESCRIPTOR),
|
||||
_descriptor.FieldDescriptor(
|
||||
name='name', full_name='MyObj.name', index=1,
|
||||
number=2, type=9, cpp_type=9, label=1,
|
||||
has_default_value=False, default_value=b"".decode('utf-8'),
|
||||
message_type=None, enum_type=None, containing_type=None,
|
||||
is_extension=False, extension_scope=None,
|
||||
serialized_options=None, file=DESCRIPTOR),
|
||||
],
|
||||
extensions=[
|
||||
],
|
||||
nested_types=[],
|
||||
enum_types=[
|
||||
],
|
||||
serialized_options=None,
|
||||
is_extendable=False,
|
||||
syntax='proto3',
|
||||
extension_ranges=[],
|
||||
oneofs=[
|
||||
],
|
||||
serialized_start=14,
|
||||
serialized_end=51,
|
||||
)
|
||||
|
||||
DESCRIPTOR.message_types_by_name['MyObj'] = _MYOBJ
|
||||
_sym_db.RegisterFileDescriptor(DESCRIPTOR)
|
||||
|
||||
MyObj = _reflection.GeneratedProtocolMessageType('MyObj', (_message.Message,), {
|
||||
'DESCRIPTOR' : _MYOBJ,
|
||||
'__module__' : 'test_pb2'
|
||||
# @@protoc_insertion_point(class_scope:MyObj)
|
||||
})
|
||||
_sym_db.RegisterMessage(MyObj)
|
||||
|
||||
|
||||
# @@protoc_insertion_point(module_scope)
|
@ -1,42 +1,59 @@
|
||||
use derive_more::Display;
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::pin::Pin;
|
||||
use std::task;
|
||||
use std::task::Poll;
|
||||
//! Protobuf payload extractor for Actix Web.
|
||||
|
||||
use bytes::BytesMut;
|
||||
use prost::DecodeError as ProtoBufDecodeError;
|
||||
use prost::EncodeError as ProtoBufEncodeError;
|
||||
use prost::Message;
|
||||
#![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))]
|
||||
|
||||
use actix_web::dev::{HttpResponseBuilder, Payload};
|
||||
use actix_web::error::{Error, PayloadError, ResponseError};
|
||||
use actix_web::http::header::{CONTENT_LENGTH, CONTENT_TYPE};
|
||||
use actix_web::{FromRequest, HttpMessage, HttpRequest, HttpResponse, Responder};
|
||||
use futures::future::{ready, FutureExt, LocalBoxFuture, Ready};
|
||||
use futures::StreamExt;
|
||||
use std::{
|
||||
fmt,
|
||||
future::Future,
|
||||
ops::{Deref, DerefMut},
|
||||
pin::Pin,
|
||||
task::{self, Poll},
|
||||
};
|
||||
|
||||
use actix_web::{
|
||||
body::BoxBody,
|
||||
dev::Payload,
|
||||
error::PayloadError,
|
||||
http::header::{CONTENT_LENGTH, CONTENT_TYPE},
|
||||
web::BytesMut,
|
||||
Error, FromRequest, HttpMessage, HttpRequest, HttpResponse, HttpResponseBuilder, Responder,
|
||||
ResponseError,
|
||||
};
|
||||
use derive_more::derive::Display;
|
||||
use futures_util::{
|
||||
future::{FutureExt as _, LocalBoxFuture},
|
||||
stream::StreamExt as _,
|
||||
};
|
||||
use prost::{DecodeError as ProtoBufDecodeError, EncodeError as ProtoBufEncodeError, Message};
|
||||
|
||||
#[derive(Debug, Display)]
|
||||
pub enum ProtoBufPayloadError {
|
||||
/// Payload size is bigger than 256k
|
||||
#[display(fmt = "Payload size is bigger than 256k")]
|
||||
#[display("Payload size is bigger than 256k")]
|
||||
Overflow,
|
||||
|
||||
/// Content type error
|
||||
#[display(fmt = "Content type error")]
|
||||
#[display("Content type error")]
|
||||
ContentType,
|
||||
|
||||
/// Serialize error
|
||||
#[display(fmt = "ProtoBuf serialize error: {}", _0)]
|
||||
#[display("ProtoBuf serialize error: {_0}")]
|
||||
Serialize(ProtoBufEncodeError),
|
||||
|
||||
/// Deserialize error
|
||||
#[display(fmt = "ProtoBuf deserialize error: {}", _0)]
|
||||
#[display("ProtoBuf deserialize error: {_0}")]
|
||||
Deserialize(ProtoBufDecodeError),
|
||||
|
||||
/// Payload error
|
||||
#[display(fmt = "Error that occur during reading payload: {}", _0)]
|
||||
#[display("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 {
|
||||
@ -78,7 +95,7 @@ impl<T: Message> fmt::Debug for ProtoBuf<T>
|
||||
where
|
||||
T: fmt::Debug,
|
||||
{
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "ProtoBuf: {:?}", self.0)
|
||||
}
|
||||
}
|
||||
@ -87,7 +104,7 @@ impl<T: Message> fmt::Display for ProtoBuf<T>
|
||||
where
|
||||
T: fmt::Display,
|
||||
{
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
@ -114,7 +131,6 @@ impl<T> FromRequest for ProtoBuf<T>
|
||||
where
|
||||
T: Message + Default + 'static,
|
||||
{
|
||||
type Config = ProtoBufConfig;
|
||||
type Error = Error;
|
||||
type Future = LocalBoxFuture<'static, Result<Self, Error>>;
|
||||
|
||||
@ -135,21 +151,16 @@ where
|
||||
}
|
||||
|
||||
impl<T: Message + Default> Responder for ProtoBuf<T> {
|
||||
type Error = Error;
|
||||
type Future = Ready<Result<HttpResponse, Error>>;
|
||||
type Body = BoxBody;
|
||||
|
||||
fn respond_to(self, _: &HttpRequest) -> Self::Future {
|
||||
fn respond_to(self, _: &HttpRequest) -> HttpResponse {
|
||||
let mut buf = Vec::new();
|
||||
ready(
|
||||
self.0
|
||||
.encode(&mut buf)
|
||||
.map_err(|e| Error::from(ProtoBufPayloadError::Serialize(e)))
|
||||
.and_then(|()| {
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/protobuf")
|
||||
.body(buf))
|
||||
}),
|
||||
)
|
||||
match self.0.encode(&mut buf) {
|
||||
Ok(()) => HttpResponse::Ok()
|
||||
.content_type("application/protobuf")
|
||||
.body(buf),
|
||||
Err(err) => HttpResponse::from_error(Error::from(ProtoBufPayloadError::Serialize(err))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,7 +175,9 @@ 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" {
|
||||
if req.content_type() != "application/protobuf"
|
||||
&& req.content_type() != "application/x-protobuf"
|
||||
{
|
||||
return ProtoBufMessage {
|
||||
limit: 262_144,
|
||||
length: None,
|
||||
@ -202,10 +215,7 @@ impl<T: Message + Default> ProtoBufMessage<T> {
|
||||
impl<T: Message + Default + 'static> Future for ProtoBufMessage<T> {
|
||||
type Output = Result<T, ProtoBufPayloadError>;
|
||||
|
||||
fn poll(
|
||||
mut self: Pin<&mut Self>,
|
||||
task: &mut task::Context<'_>,
|
||||
) -> Poll<Self::Output> {
|
||||
fn poll(mut self: Pin<&mut Self>, task: &mut task::Context<'_>) -> Poll<Self::Output> {
|
||||
if let Some(ref mut fut) = self.fut {
|
||||
return Pin::new(fut).poll(task);
|
||||
}
|
||||
@ -239,7 +249,7 @@ impl<T: Message + Default + 'static> Future for ProtoBufMessage<T> {
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(<T>::decode(&mut body)?);
|
||||
Ok(<T>::decode(&mut body)?)
|
||||
}
|
||||
.boxed_local(),
|
||||
);
|
||||
@ -253,39 +263,38 @@ pub trait ProtoBufResponseBuilder {
|
||||
|
||||
impl ProtoBufResponseBuilder for HttpResponseBuilder {
|
||||
fn protobuf<T: Message>(&mut self, value: T) -> Result<HttpResponse, Error> {
|
||||
self.header(CONTENT_TYPE, "application/protobuf");
|
||||
self.insert_header((CONTENT_TYPE, "application/protobuf"));
|
||||
|
||||
let mut body = Vec::new();
|
||||
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 {
|
||||
match *self {
|
||||
ProtoBufPayloadError::Overflow => match *other {
|
||||
ProtoBufPayloadError::Overflow => true,
|
||||
_ => false,
|
||||
},
|
||||
ProtoBufPayloadError::ContentType => match *other {
|
||||
ProtoBufPayloadError::ContentType => true,
|
||||
_ => false,
|
||||
},
|
||||
ProtoBufPayloadError::Overflow => {
|
||||
matches!(*other, ProtoBufPayloadError::Overflow)
|
||||
}
|
||||
ProtoBufPayloadError::ContentType => {
|
||||
matches!(*other, ProtoBufPayloadError::ContentType)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
#[derive(Clone, PartialEq, Eq, Message)]
|
||||
pub struct MyObject {
|
||||
#[prost(int32, tag = "1")]
|
||||
pub number: i32,
|
||||
@ -293,36 +302,34 @@ mod tests {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[actix_web::test]
|
||||
async fn test_protobuf() {
|
||||
let protobuf = ProtoBuf(MyObject {
|
||||
number: 9,
|
||||
name: "test".to_owned(),
|
||||
});
|
||||
let req = TestRequest::default().to_http_request();
|
||||
let resp = protobuf.respond_to(&req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.headers().get(header::CONTENT_TYPE).unwrap(),
|
||||
"application/protobuf"
|
||||
);
|
||||
let resp = protobuf.respond_to(&req);
|
||||
let ct = resp.headers().get(header::CONTENT_TYPE).unwrap();
|
||||
assert_eq!(ct, "application/protobuf");
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[actix_web::test]
|
||||
async fn test_protobuf_message() {
|
||||
let (req, mut pl) = TestRequest::default().to_http_parts();
|
||||
let protobuf = ProtoBufMessage::<MyObject>::new(&req, &mut pl).await;
|
||||
assert_eq!(protobuf.err().unwrap(), ProtoBufPayloadError::ContentType);
|
||||
|
||||
let (req, mut pl) =
|
||||
TestRequest::with_header(header::CONTENT_TYPE, "application/text")
|
||||
.to_http_parts();
|
||||
let (req, mut pl) = TestRequest::get()
|
||||
.insert_header((header::CONTENT_TYPE, "application/text"))
|
||||
.to_http_parts();
|
||||
let protobuf = ProtoBufMessage::<MyObject>::new(&req, &mut pl).await;
|
||||
assert_eq!(protobuf.err().unwrap(), ProtoBufPayloadError::ContentType);
|
||||
|
||||
let (req, mut pl) =
|
||||
TestRequest::with_header(header::CONTENT_TYPE, "application/protobuf")
|
||||
.header(header::CONTENT_LENGTH, "10000")
|
||||
.to_http_parts();
|
||||
let (req, mut pl) = TestRequest::get()
|
||||
.insert_header((header::CONTENT_TYPE, "application/protobuf"))
|
||||
.insert_header((header::CONTENT_LENGTH, "10000"))
|
||||
.to_http_parts();
|
||||
let protobuf = ProtoBufMessage::<MyObject>::new(&req, &mut pl)
|
||||
.limit(100)
|
||||
.await;
|
||||
|
@ -1,96 +0,0 @@
|
||||
# Changes
|
||||
|
||||
## [0.9.0-alpha.2]
|
||||
|
||||
* 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]
|
||||
|
||||
* 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 (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
|
@ -1,52 +0,0 @@
|
||||
[package]
|
||||
name = "actix-redis"
|
||||
version = "0.9.0-alpha.2"
|
||||
authors = ["Nikolay Kim <fafhrd91@gmail.com>"]
|
||||
description = "Redis integration for actix framework"
|
||||
license = "MIT/Apache-2.0"
|
||||
readme = "README.md"
|
||||
keywords = ["web", "redis", "async", "actix", "tokio"]
|
||||
homepage = "https://github.com/actix/actix-extras"
|
||||
repository = "https://github.com/actix/actix-extras.git"
|
||||
documentation = "https://docs.rs/actix-redis/"
|
||||
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-http/actors", "actix-service", "actix-web", "actix-session/cookie-session", "rand", "serde", "serde_json"]
|
||||
|
||||
[dependencies]
|
||||
actix = "0.10.0-alpha.3"
|
||||
actix-utils = "1.0.3"
|
||||
|
||||
log = "0.4.6"
|
||||
backoff = "0.1.5"
|
||||
derive_more = "0.99.2"
|
||||
futures = "0.3.1"
|
||||
redis-async = "0.6.1"
|
||||
actix-rt = "1.0.0"
|
||||
time = "0.2.9"
|
||||
tokio = "0.2.6"
|
||||
tokio-util = "0.3.0"
|
||||
|
||||
# actix-session
|
||||
actix-web = { version = "3.0.0-alpha.2", optional = true }
|
||||
actix-http = { version = "2.0.0-alpha.3", optional = true }
|
||||
actix-service = { version = "1.0.0", optional = true }
|
||||
actix-session = { version = "0.4.0-alpha.1", optional = true }
|
||||
rand = { version = "0.7.0", optional = true }
|
||||
serde = { version = "1.0.101", optional = true, features = ["derive"] }
|
||||
serde_json = { version = "1.0.40", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
env_logger = "0.7"
|
||||
serde_derive = "1.0"
|
||||
serde_json = "1.0"
|
@ -1,66 +0,0 @@
|
||||
# actix-redis
|
||||
|
||||
[](https://crates.io/crates/actix-redis)
|
||||
[](https://docs.rs/actix-redis)
|
||||
[](https://deps.rs/crate/actix-redis/0.8.1)
|
||||

|
||||
[](https://gitter.im/actix/actix?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||
|
||||
> Redis integration for actix framework.
|
||||
|
||||
## Documentation
|
||||
|
||||
* [API Documentation](https://actix.rs/actix-extras/actix_redis/)
|
||||
* [Chat on gitter](https://gitter.im/actix/actix)
|
||||
* Cargo package: [actix-redis](https://crates.io/crates/actix-redis)
|
||||
* Minimum supported Rust version: 1.40 or later
|
||||
|
||||
## 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 `RedisSessionBackend`. 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, web, middleware};
|
||||
use actix_web::middleware::session::SessionStorage;
|
||||
use actix_redis::RedisSessionBackend;
|
||||
|
||||
#[actix_rt::main]
|
||||
async fn main() -> std::io::Result {
|
||||
HttpServer::new(|| App::new()
|
||||
// enable logger
|
||||
.middleware(middleware::Logger::default())
|
||||
// cookie session middleware
|
||||
.middleware(SessionStorage::new(
|
||||
RedisSessionBackend::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")?
|
||||
.start()
|
||||
.await
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under either of
|
||||
|
||||
* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or [https://www.apache.org/licenses/LICENSE-2.0](https://www.apache.org/licenses/LICENSE-2.0))
|
||||
* MIT license ([LICENSE-MIT](LICENSE-MIT) or [https://opensource.org/licenses/MIT](https://opensource.org/licenses/MIT))
|
||||
|
||||
at your option.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Contribution to the actix-redis crate is organized under the terms of the
|
||||
Contributor Covenant, the maintainer of actix-redis, @fafhrd91, promises to
|
||||
intervene to uphold that code of conduct.
|
@ -1,37 +0,0 @@
|
||||
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.set("counter", count + 1)?;
|
||||
} else {
|
||||
session.set("counter", 1)?;
|
||||
}
|
||||
|
||||
Ok("Welcome!")
|
||||
}
|
||||
|
||||
#[actix_rt::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
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
//! Redis integration for Actix framework.
|
||||
//!
|
||||
//! ## Documentation
|
||||
//! * [API Documentation (Development)](http://actix.github.io/actix-redis/actix_redis/)
|
||||
//! * [API Documentation (Releases)](https://docs.rs/actix-redis/)
|
||||
//! * [Chat on gitter](https://gitter.im/actix/actix)
|
||||
//! * Cargo package: [actix-redis](https://crates.io/crates/actix-redis)
|
||||
//! * Minimum supported Rust version: 1.26 or later
|
||||
//!
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
#[macro_use]
|
||||
extern crate redis_async;
|
||||
#[macro_use]
|
||||
extern crate derive_more;
|
||||
|
||||
mod redis;
|
||||
pub use redis::{Command, RedisActor};
|
||||
|
||||
#[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, 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;
|
@ -1,147 +0,0 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::io;
|
||||
|
||||
use actix::actors::resolver::{Connect, Resolver};
|
||||
use actix::prelude::*;
|
||||
use actix_utils::oneshot;
|
||||
use backoff::backoff::Backoff;
|
||||
use backoff::ExponentialBackoff;
|
||||
use futures::FutureExt;
|
||||
use redis_async::error::Error as RespError;
|
||||
use redis_async::resp::{RespCodec, RespValue};
|
||||
use tokio::io::{split, WriteHalf};
|
||||
use tokio::net::TcpStream;
|
||||
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 comminucation actor
|
||||
pub struct RedisActor {
|
||||
addr: String,
|
||||
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 mut backoff = ExponentialBackoff::default();
|
||||
backoff.max_elapsed_time = None;
|
||||
|
||||
Supervisor::start(|_| RedisActor {
|
||||
addr,
|
||||
cell: None,
|
||||
backoff,
|
||||
queue: VecDeque::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for RedisActor {
|
||||
type Context = Context<Self>;
|
||||
|
||||
fn started(&mut self, ctx: &mut Context<Self>) {
|
||||
Resolver::from_registry()
|
||||
.send(Connect::host(self.addr.as_str()))
|
||||
.into_actor(self)
|
||||
.map(|res, act, ctx| match res {
|
||||
Ok(res) => match res {
|
||||
Ok(stream) => {
|
||||
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());
|
||||
}
|
||||
}
|
||||
},
|
||||
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(rx.map(|res| match res {
|
||||
Ok(res) => res,
|
||||
Err(_) => Err(Error::Disconnected),
|
||||
}))
|
||||
}
|
||||
}
|
@ -1,668 +0,0 @@
|
||||
use std::cell::RefCell;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
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};
|
||||
use actix_web::dev::{ServiceRequest, ServiceResponse};
|
||||
use actix_web::http::header::{self, HeaderValue};
|
||||
use actix_web::{error, Error, HttpMessage};
|
||||
use futures::future::{ok, Future, Ready};
|
||||
use rand::{distributions::Alphanumeric, rngs::OsRng, Rng};
|
||||
use redis_async::resp::RespValue;
|
||||
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 `RedisSessionBackend`. 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::from_master(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: Some(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`
|
||||
pub fn cookie_secure(mut self, secure: bool) -> Self {
|
||||
Rc::get_mut(&mut self.0).unwrap().secure = secure;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set custom cookie max-age
|
||||
pub fn cookie_max_age(mut self, max_age: Duration) -> Self {
|
||||
Rc::get_mut(&mut self.0).unwrap().max_age = Some(max_age);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set custom cookie SameSite
|
||||
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
|
||||
pub fn cookie_http_only(mut self, http_only: bool) -> Self {
|
||||
Rc::get_mut(&mut self.0).unwrap().http_only = Some(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> for RedisSession
|
||||
where
|
||||
S: Service<Request = ServiceRequest, Response = ServiceResponse<B>, Error = Error>
|
||||
+ 'static,
|
||||
S::Future: 'static,
|
||||
B: 'static,
|
||||
{
|
||||
type Request = ServiceRequest;
|
||||
type Response = ServiceResponse<B>;
|
||||
type Error = S::Error;
|
||||
type InitError = ();
|
||||
type Transform = RedisSessionMiddleware<S>;
|
||||
type Future = Ready<Result<Self::Transform, Self::InitError>>;
|
||||
|
||||
fn new_transform(&self, service: S) -> Self::Future {
|
||||
ok(RedisSessionMiddleware {
|
||||
service: Rc::new(RefCell::new(service)),
|
||||
inner: self.0.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Cookie session middleware
|
||||
pub struct RedisSessionMiddleware<S: 'static> {
|
||||
service: Rc<RefCell<S>>,
|
||||
inner: Rc<Inner>,
|
||||
}
|
||||
|
||||
impl<S, B> Service for RedisSessionMiddleware<S>
|
||||
where
|
||||
S: Service<Request = ServiceRequest, Response = ServiceResponse<B>, Error = Error>
|
||||
+ 'static,
|
||||
S::Future: 'static,
|
||||
B: 'static,
|
||||
{
|
||||
type Request = ServiceRequest;
|
||||
type Response = ServiceResponse<B>;
|
||||
type Error = Error;
|
||||
#[allow(clippy::type_complexity)]
|
||||
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.service.borrow_mut().poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, mut req: ServiceRequest) -> Self::Future {
|
||||
let mut srv = self.service.clone();
|
||||
let inner = self.inner.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
let state = inner.load(&req).await?;
|
||||
let value = if let Some((state, value)) = state {
|
||||
Session::set_session(state.into_iter(), &mut req);
|
||||
Some(value)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut res = srv.call(req).await?;
|
||||
|
||||
match Session::get_changes(&mut res) {
|
||||
(SessionStatus::Unchanged, None) => Ok(res),
|
||||
(SessionStatus::Unchanged, Some(state)) => {
|
||||
if value.is_none() {
|
||||
// implies the session is new
|
||||
inner.update(res, state, value).await
|
||||
} else {
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
(SessionStatus::Changed, Some(state)) => {
|
||||
inner.update(res, state, value).await
|
||||
}
|
||||
(SessionStatus::Purged, Some(_)) => {
|
||||
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, Some(state)) => {
|
||||
if let Some(val) = value {
|
||||
inner.clear_cache(val).await?;
|
||||
inner.update(res, state, None).await
|
||||
} else {
|
||||
inner.update(res, state, None).await
|
||||
}
|
||||
}
|
||||
(_, None) => unreachable!(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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: Option<bool>,
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
async fn load(
|
||||
&self,
|
||||
req: &ServiceRequest,
|
||||
) -> Result<Option<(HashMap<String, String>, String)>, Error> {
|
||||
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());
|
||||
if let Some(cookie) = jar.signed(&self.key).get(&self.name) {
|
||||
let value = cookie.value().to_owned();
|
||||
let cachekey = (self.cache_keygen)(&cookie.value());
|
||||
return match self
|
||||
.addr
|
||||
.send(Command(resp_array!["GET", cachekey]))
|
||||
.await
|
||||
{
|
||||
Err(e) => Err(Error::from(e)),
|
||||
Ok(res) => match res {
|
||||
Ok(val) => {
|
||||
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)
|
||||
}
|
||||
Err(err) => Err(error::ErrorInternalServerError(err)),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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: String = iter::repeat(())
|
||||
.map(|()| OsRng.sample(Alphanumeric))
|
||||
.take(32)
|
||||
.collect();
|
||||
|
||||
// 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.unwrap_or(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(same_site) = self.same_site {
|
||||
cookie.set_same_site(same_site);
|
||||
}
|
||||
|
||||
// set cookie
|
||||
let mut jar = CookieJar::new();
|
||||
jar.signed(&self.key).add(cookie);
|
||||
|
||||
(value, Some(jar))
|
||||
};
|
||||
|
||||
let cachekey = (self.cache_keygen)(&value);
|
||||
|
||||
let state: HashMap<_, _> = state.collect();
|
||||
match serde_json::to_string(&state) {
|
||||
Err(e) => Err(e.into()),
|
||||
Ok(body) => {
|
||||
match self
|
||||
.addr
|
||||
.send(Command(resp_array!["SET", cachekey, body, "EX", &self.ttl]))
|
||||
.await
|
||||
{
|
||||
Err(e) => Err(Error::from(e)),
|
||||
Ok(redis_result) => match redis_result {
|
||||
Ok(_) => {
|
||||
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)
|
||||
}
|
||||
Err(err) => Err(error::ErrorInternalServerError(err)),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// removes cache entry
|
||||
async fn clear_cache(&self, key: String) -> Result<(), Error> {
|
||||
let cachekey = (self.cache_keygen)(&key);
|
||||
|
||||
match self.addr.send(Command(resp_array!["DEL", cachekey])).await {
|
||||
Err(e) => Err(Error::from(e)),
|
||||
Ok(res) => {
|
||||
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, test, 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.set("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.set("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")?;
|
||||
if let Some(x) = id {
|
||||
session.purge();
|
||||
Ok(format!("Logged out: {}", x).into())
|
||||
} else {
|
||||
Ok("Could not log out anonymous user".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn test_workflow() {
|
||||
// Step 1: GET index
|
||||
// - set-cookie actix-session will be in response (session cookie #1)
|
||||
// - response should be: {"counter": 0, "user_id": None}
|
||||
// Step 2: GET index, including session cookie #1 in request
|
||||
// - set-cookie will *not* be in response
|
||||
// - response should be: {"counter": 0, "user_id": None}
|
||||
// Step 3: POST to do_something, including session cookie #1 in request
|
||||
// - adds new session state in redis: {"counter": 1}
|
||||
// - 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": 2, "user_id": None}
|
||||
// Step 8: GET index, including session cookie #1 in request
|
||||
// - set-cookie actix-session will be in response (session cookie #3)
|
||||
// - 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 will be in response (session cookie #3)
|
||||
// - response should be: {"counter": 0, "user_id": None}
|
||||
|
||||
let srv = 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 will be in response (session cookie #1)
|
||||
// - response should be: {"counter": 0, "user_id": None}
|
||||
let req_1a = srv.get("/").send();
|
||||
let mut resp_1 = req_1a.await.unwrap();
|
||||
let cookie_1 = resp_1
|
||||
.cookies()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.into_iter()
|
||||
.find(|c| c.name() == "test-session")
|
||||
.unwrap();
|
||||
let result_1 = resp_1.json::<IndexResponse>().await.unwrap();
|
||||
assert_eq!(
|
||||
result_1,
|
||||
IndexResponse {
|
||||
user_id: None,
|
||||
counter: 0
|
||||
}
|
||||
);
|
||||
|
||||
// Step 2: GET index, including session cookie #1 in request
|
||||
// - set-cookie will *not* be in response
|
||||
// - response should be: {"counter": 0, "user_id": None}
|
||||
let req_2 = srv.get("/").cookie(cookie_1.clone()).send();
|
||||
let resp_2 = req_2.await.unwrap();
|
||||
let cookie_2 = resp_2
|
||||
.cookies()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.into_iter()
|
||||
.find(|c| c.name() == "test-session");
|
||||
assert_eq!(cookie_2, None);
|
||||
|
||||
// Step 3: POST to do_something, including session cookie #1 in request
|
||||
// - adds new session state in redis: {"counter": 1}
|
||||
// - response should be: {"counter": 1, "user_id": None}
|
||||
let req_3 = srv.post("/do_something").cookie(cookie_1.clone()).send();
|
||||
let mut resp_3 = req_3.await.unwrap();
|
||||
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": 2, "user_id": None}
|
||||
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 will be in response (session cookie #3)
|
||||
// - 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();
|
||||
let cookie_3 = resp_8
|
||||
.cookies()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.into_iter()
|
||||
.find(|c| c.name() == "test-session")
|
||||
.unwrap();
|
||||
let result_8 = resp_8.json::<IndexResponse>().await.unwrap();
|
||||
assert_eq!(
|
||||
result_8,
|
||||
IndexResponse {
|
||||
user_id: None,
|
||||
counter: 0
|
||||
}
|
||||
);
|
||||
assert_ne!(cookie_3.value(), cookie_2.value());
|
||||
|
||||
// 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_4 = resp_9
|
||||
.cookies()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.into_iter()
|
||||
.find(|c| c.name() == "test-session")
|
||||
.unwrap();
|
||||
assert_ne!(
|
||||
OffsetDateTime::now_utc().year(),
|
||||
cookie_4.expires().map(|t| t.year()).unwrap()
|
||||
);
|
||||
|
||||
// Step 10: GET index, including session cookie #2 in request
|
||||
// - set-cookie actix-session will be in response (session cookie #3)
|
||||
// - 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();
|
||||
let result_10 = resp_10.json::<IndexResponse>().await.unwrap();
|
||||
assert_eq!(
|
||||
result_10,
|
||||
IndexResponse {
|
||||
user_id: None,
|
||||
counter: 0
|
||||
}
|
||||
);
|
||||
|
||||
let cookie_5 = resp_10
|
||||
.cookies()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.into_iter()
|
||||
.find(|c| c.name() == "test-session")
|
||||
.unwrap();
|
||||
assert_ne!(cookie_5.value(), cookie_2.value());
|
||||
}
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
#[macro_use]
|
||||
extern crate redis_async;
|
||||
|
||||
use actix_redis::{Command, Error, RedisActor, RespValue};
|
||||
|
||||
#[actix_rt::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_rt::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),
|
||||
}
|
||||
}
|
@ -1,81 +1,240 @@
|
||||
# Changes
|
||||
|
||||
## unreleased
|
||||
## Unreleased
|
||||
|
||||
* Minimum supported Rust version(MSRV) is now 1.40.0.
|
||||
- Add `Session::contains_key` method.
|
||||
- Add `Session::update[_or]()` methods.
|
||||
- Update `redis` dependency to `0.29`.
|
||||
|
||||
## [0.4.0-alpha.1] - 2020-03-14
|
||||
## 0.10.1
|
||||
|
||||
* 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]
|
||||
* Minimize `futures` dependency
|
||||
- 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
|
||||
|
||||
- Update `actix-web` dependency to `4`.
|
||||
|
||||
## 0.5.0-beta.8
|
||||
|
||||
- Update `actix-web` dependency to `4.0.0-rc.1`.
|
||||
|
||||
## 0.5.0-beta.7
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
- No significant changes since `0.5.0-beta.3`.
|
||||
|
||||
## 0.5.0-beta.3
|
||||
|
||||
- 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.
|
||||
|
||||
[#201]: https://github.com/actix/actix-extras/pull/201
|
||||
[#203]: https://github.com/actix/actix-extras/pull/203
|
||||
|
||||
## 0.5.0-beta.2
|
||||
|
||||
- No notable changes.
|
||||
|
||||
## 0.5.0-beta.1
|
||||
|
||||
- Add `Session::entries`. [#170]
|
||||
- Rename `Session::{set => insert}` to match standard hash map naming. [#170]
|
||||
- Return values from `Session::remove`. [#170]
|
||||
- Add `Session::remove_as` deserializing variation. [#170]
|
||||
- Simplify `Session::get_changes` now always returning iterator even when empty. [#170]
|
||||
- Swap order of arguments on `Session::set_session`. [#170]
|
||||
- Update `actix-web` dependency to 4.0.0 beta.
|
||||
- Minimum supported Rust version (MSRV) is now 1.46.0.
|
||||
|
||||
[#170]: https://github.com/actix/actix-extras/pull/170
|
||||
|
||||
## 0.4.1
|
||||
|
||||
- `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
|
||||
|
||||
- Update `actix-web` dependency to 3.0.0.
|
||||
- Minimum supported Rust version (MSRV) is now 1.42.0.
|
||||
|
||||
## 0.4.0-alpha.1
|
||||
|
||||
- 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]
|
||||
- Minimize `futures` dependency
|
||||
|
||||
[#1292]: https://github.com/actix/actix-web/pull/1292
|
||||
|
||||
## [0.3.0] - 2019-12-20
|
||||
## 0.3.0 - 2019-12-20
|
||||
|
||||
* Release
|
||||
- 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
|
||||
- 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
|
||||
- Add access to the session from RequestHead for use of session from guard methods
|
||||
- Migrate to `std::future`
|
||||
- Migrate to `actix-web` 2.0
|
||||
|
||||
* Migrate to `std::future`
|
||||
## 0.2.0 - 2019-07-08
|
||||
|
||||
* Migrate to `actix-web` 2.0
|
||||
- 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
|
||||
## 0.1.1 - 2019-06-03
|
||||
|
||||
* 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).
|
||||
- Fix optional cookie session support
|
||||
|
||||
## [0.1.1] - 2019-06-03
|
||||
## 0.1.0 - 2019-05-18
|
||||
|
||||
* Fix optional cookie session support
|
||||
- Use actix-web 1.0.0-rc
|
||||
|
||||
## [0.1.0] - 2019-05-18
|
||||
## 0.1.0-beta.4 - 2019-05-12
|
||||
|
||||
* Use actix-web 1.0.0-rc
|
||||
- Use actix-web 1.0.0-beta.4
|
||||
|
||||
## [0.1.0-beta.4] - 2019-05-12
|
||||
## 0.1.0-beta.2 - 2019-04-28
|
||||
|
||||
* Use actix-web 1.0.0-beta.4
|
||||
- Add helper trait `UserSession` which allows to get session for ServiceRequest and HttpRequest
|
||||
|
||||
## [0.1.0-beta.2] - 2019-04-28
|
||||
## 0.1.0-beta.1 - 2019-04-20
|
||||
|
||||
* Add helper trait `UserSession` which allows to get session for ServiceRequest and HttpRequest
|
||||
- Update actix-web to beta.1
|
||||
- `CookieSession::max_age()` accepts value in seconds
|
||||
|
||||
## [0.1.0-beta.1] - 2019-04-20
|
||||
## 0.1.0-alpha.6 - 2019-04-14
|
||||
|
||||
* Update actix-web to beta.1
|
||||
- Update actix-web alpha.6
|
||||
|
||||
* `CookieSession::max_age()` accepts value in seconds
|
||||
## 0.1.0-alpha.4 - 2019-04-08
|
||||
|
||||
## [0.1.0-alpha.6] - 2019-04-14
|
||||
- Update actix-web
|
||||
|
||||
* Update actix-web alpha.6
|
||||
## 0.1.0-alpha.3 - 2019-04-02
|
||||
|
||||
## [0.1.0-alpha.4] - 2019-04-08
|
||||
- Update actix-web
|
||||
|
||||
* Update actix-web
|
||||
## 0.1.0-alpha.2 - 2019-03-29
|
||||
|
||||
## [0.1.0-alpha.3] - 2019-04-02
|
||||
- Update actix-web
|
||||
- Use new feature name for secure cookies
|
||||
|
||||
* Update actix-web
|
||||
## 0.1.0-alpha.1 - 2019-03-28
|
||||
|
||||
## [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
|
||||
|
||||
* Initial impl
|
||||
- Initial impl
|
||||
|
@ -1,35 +1,60 @@
|
||||
[package]
|
||||
name = "actix-session"
|
||||
version = "0.4.0-alpha.1"
|
||||
authors = ["Nikolay Kim <fafhrd91@gmail.com>"]
|
||||
description = "Session for actix-web framework."
|
||||
readme = "README.md"
|
||||
keywords = ["http", "web", "framework", "async", "futures"]
|
||||
homepage = "https://actix.rs"
|
||||
repository = "https://github.com/actix/actix-web.git"
|
||||
documentation = "https://docs.rs/actix-session/"
|
||||
license = "MIT/Apache-2.0"
|
||||
edition = "2018"
|
||||
version = "0.10.1"
|
||||
authors = [
|
||||
"Nikolay Kim <fafhrd91@gmail.com>",
|
||||
"Luca Palmieri <rust@lpalmieri.com>",
|
||||
]
|
||||
description = "Session management 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
|
||||
|
||||
[lib]
|
||||
name = "actix_session"
|
||||
path = "src/lib.rs"
|
||||
[package.metadata.docs.rs]
|
||||
rustdoc-args = ["--cfg", "docsrs"]
|
||||
all-features = true
|
||||
|
||||
[features]
|
||||
default = ["cookie-session"]
|
||||
|
||||
# sessions feature, session require "ring" crate and c compiler
|
||||
cookie-session = ["actix-web/secure-cookies"]
|
||||
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"]
|
||||
|
||||
[dependencies]
|
||||
actix-web = "3.0.0-alpha.1"
|
||||
actix-service = "1.0.1"
|
||||
bytes = "0.5.3"
|
||||
derive_more = "0.99.2"
|
||||
futures-util = { version = "0.3.4", default-features = false }
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
time = { version = "0.2.7", default-features = false, features = ["std"] }
|
||||
actix-service = "2"
|
||||
actix-utils = "3"
|
||||
actix-web = { version = "4", default-features = false, features = ["cookies", "secure-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 }
|
||||
|
||||
[dev-dependencies]
|
||||
actix-rt = "1.0.0"
|
||||
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"]
|
||||
|
@ -1,19 +1,125 @@
|
||||
# actix-session
|
||||
|
||||
[](https://crates.io/crates/actix-session)
|
||||
[](https://docs.rs/actix-session)
|
||||
[](https://deps.rs/crate/actix-session/0.3.0)
|
||||
[](https://travis-ci.org/actix/actix-session)
|
||||
[](https://codecov.io/gh/actix/actix-session)
|
||||
> Session management for Actix Web.
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
[](https://crates.io/crates/actix-session)
|
||||
[](https://docs.rs/actix-session/0.10.1)
|
||||

|
||||
[](https://gitter.im/actix/actix?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||
[](https://deps.rs/crate/actix-session/0.10.1)
|
||||
|
||||
> Session for actix-web framework.
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
## Documentation & community resources
|
||||
<!-- cargo-rdme start -->
|
||||
|
||||
* [User Guide](https://actix.rs/docs/)
|
||||
* [API Documentation](https://docs.rs/actix-session/)
|
||||
* [Chat on gitter](https://gitter.im/actix/actix)
|
||||
* Cargo package: [actix-session](https://crates.io/crates/actix-session)
|
||||
* Minimum supported Rust version: 1.40 or later
|
||||
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 -->
|
||||
|
@ -1,11 +1,12 @@
|
||||
#[macro_use]
|
||||
extern crate serde_derive;
|
||||
|
||||
use actix_redis::RedisSession;
|
||||
use actix_session::Session;
|
||||
use actix_session::{storage::RedisSessionStore, Session, SessionMiddleware};
|
||||
use actix_web::{
|
||||
cookie, middleware, web, App, Error, HttpResponse, HttpServer, Responder,
|
||||
cookie::{Key, SameSite},
|
||||
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 {
|
||||
@ -21,8 +22,8 @@ struct User {
|
||||
}
|
||||
|
||||
impl User {
|
||||
fn authenticate(credentials: Credentials) -> Result<Self, actix_http::Response> {
|
||||
// TODO: figure out why I keep getting hacked
|
||||
fn authenticate(credentials: Credentials) -> Result<Self, HttpResponse> {
|
||||
// to do: figure out why I keep getting hacked /s
|
||||
if &credentials.password != "hunter2" {
|
||||
return Err(HttpResponse::Unauthorized().json("Unauthorized"));
|
||||
}
|
||||
@ -35,7 +36,7 @@ impl User {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_session(session: &Session) -> Result<i64, actix_http::Response> {
|
||||
pub fn validate_session(session: &Session) -> Result<i64, HttpResponse> {
|
||||
let user_id: Option<i64> = session.get("user_id").unwrap_or(None);
|
||||
|
||||
match user_id {
|
||||
@ -51,12 +52,12 @@ pub fn validate_session(session: &Session) -> Result<i64, actix_http::Response>
|
||||
async fn login(
|
||||
credentials: web::Json<Credentials>,
|
||||
session: Session,
|
||||
) -> Result<impl Responder, actix_http::Response> {
|
||||
) -> Result<impl Responder, Error> {
|
||||
let credentials = credentials.into_inner();
|
||||
|
||||
match User::authenticate(credentials) {
|
||||
Ok(user) => session.set("user_id", user.id).unwrap(),
|
||||
Err(_) => return Err(HttpResponse::Unauthorized().json("Unauthorized")),
|
||||
Ok(user) => session.insert("user_id", user.id).unwrap(),
|
||||
Err(err) => return Err(InternalError::from_response("", err).into()),
|
||||
};
|
||||
|
||||
Ok("Welcome!")
|
||||
@ -65,32 +66,46 @@ async fn login(
|
||||
/// some protected resource
|
||||
async fn secret(session: Session) -> Result<impl Responder, Error> {
|
||||
// only allow access to this resource if the user has an active session
|
||||
validate_session(&session)?;
|
||||
validate_session(&session).map_err(|err| InternalError::from_response("", err))?;
|
||||
|
||||
Ok("secret revealed")
|
||||
}
|
||||
|
||||
#[actix_rt::main]
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
std::env::set_var("RUST_LOG", "actix_web=info,actix_redis=info");
|
||||
env_logger::init();
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::builder()
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
.from_env_lossy(),
|
||||
)
|
||||
.init();
|
||||
|
||||
HttpServer::new(|| {
|
||||
// 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(
|
||||
RedisSession::new("127.0.0.1:6379", &[0; 32])
|
||||
SessionMiddleware::builder(storage.clone(), signing_key.clone())
|
||||
// allow the cookie to be accessed from javascript
|
||||
.cookie_http_only(false)
|
||||
// allow the cookie only from the current domain
|
||||
.cookie_same_site(cookie::SameSite::Strict),
|
||||
.cookie_same_site(SameSite::Strict)
|
||||
.build(),
|
||||
)
|
||||
.route("/login", web::post().to(login))
|
||||
.route("/secret", web::get().to(secret))
|
||||
})
|
||||
.bind("0.0.0.0:8080")?
|
||||
.bind(("127.0.0.1", 8080))?
|
||||
.run()
|
||||
.await
|
||||
}
|
51
actix-session/examples/basic.rs
Normal file
51
actix-session/examples/basic.rs
Normal file
@ -0,0 +1,51 @@
|
||||
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
|
||||
}
|
397
actix-session/src/config.rs
Normal file
397
actix-session/src/config.rs
Normal file
@ -0,0 +1,397 @@
|
||||
//! 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(),
|
||||
}
|
||||
}
|
@ -1,530 +0,0 @@
|
||||
//! Cookie session.
|
||||
//!
|
||||
//! [**CookieSession**](struct.CookieSession.html)
|
||||
//! uses cookies as 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 a
|
||||
//! 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 may be viewed but not modified by the client. A *private*
|
||||
//! cookie 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.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use actix_service::{Service, Transform};
|
||||
use actix_web::cookie::{Cookie, CookieJar, Key, SameSite};
|
||||
use actix_web::dev::{ServiceRequest, ServiceResponse};
|
||||
use actix_web::http::{header::SET_COOKIE, HeaderValue};
|
||||
use actix_web::{Error, HttpMessage, ResponseError};
|
||||
use derive_more::{Display, From};
|
||||
use futures_util::future::{ok, FutureExt, LocalBoxFuture, Ready};
|
||||
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, From, 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 {}
|
||||
|
||||
enum CookieSecurity {
|
||||
Signed,
|
||||
Private,
|
||||
}
|
||||
|
||||
struct CookieSessionInner {
|
||||
key: Key,
|
||||
security: CookieSecurity,
|
||||
name: String,
|
||||
path: String,
|
||||
domain: Option<String>,
|
||||
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::from_master(key),
|
||||
name: "actix-session".to_owned(),
|
||||
path: "/".to_owned(),
|
||||
domain: None,
|
||||
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();
|
||||
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(&self.key).add(cookie),
|
||||
CookieSecurity::Private => jar.private(&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_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 `CookieSessionBackend` 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.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use actix_session::CookieSession;
|
||||
/// use actix_web::{web, App, HttpResponse, HttpServer};
|
||||
///
|
||||
/// fn main() {
|
||||
/// 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()));
|
||||
/// }
|
||||
/// ```
|
||||
pub struct CookieSession(Rc<CookieSessionInner>);
|
||||
|
||||
impl CookieSession {
|
||||
/// Construct new *signed* `CookieSessionBackend` 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* `CookieSessionBackend` 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
|
||||
}
|
||||
|
||||
/// 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: 'static> Transform<S> for CookieSession
|
||||
where
|
||||
S: Service<Request = ServiceRequest, Response = ServiceResponse<B>>,
|
||||
S::Future: 'static,
|
||||
S::Error: 'static,
|
||||
{
|
||||
type Request = ServiceRequest;
|
||||
type Response = ServiceResponse<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 session middleware
|
||||
pub struct CookieSessionMiddleware<S> {
|
||||
service: S,
|
||||
inner: Rc<CookieSessionInner>,
|
||||
}
|
||||
|
||||
impl<S, B: 'static> Service for CookieSessionMiddleware<S>
|
||||
where
|
||||
S: Service<Request = ServiceRequest, Response = ServiceResponse<B>>,
|
||||
S::Future: 'static,
|
||||
S::Error: 'static,
|
||||
{
|
||||
type Request = ServiceRequest;
|
||||
type Response = ServiceResponse<B>;
|
||||
type Error = S::Error;
|
||||
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context) -> Poll<Result<(), Self::Error>> {
|
||||
self.service.poll_ready(cx)
|
||||
}
|
||||
|
||||
/// 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(&mut 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(state.into_iter(), &mut req);
|
||||
|
||||
let fut = self.service.call(req);
|
||||
|
||||
async move {
|
||||
fut.await.map(|mut res| {
|
||||
match Session::get_changes(&mut res) {
|
||||
(SessionStatus::Changed, Some(state))
|
||||
| (SessionStatus::Renewed, Some(state)) => {
|
||||
res.checked_expr(|res| inner.set_cookie(res, state))
|
||||
}
|
||||
(SessionStatus::Unchanged, Some(state)) if prolong_expiration => {
|
||||
res.checked_expr(|res| inner.set_cookie(res, state))
|
||||
}
|
||||
(SessionStatus::Unchanged, _) =>
|
||||
// set a new session cookie upon first request (new client)
|
||||
{
|
||||
if is_new {
|
||||
let state: HashMap<String, String> = HashMap::new();
|
||||
res.checked_expr(|res| {
|
||||
inner.set_cookie(res, state.into_iter())
|
||||
})
|
||||
} else {
|
||||
res
|
||||
}
|
||||
}
|
||||
(SessionStatus::Purged, _) => {
|
||||
let _ = inner.remove_cookie(&mut res);
|
||||
res
|
||||
}
|
||||
_ => res,
|
||||
}
|
||||
})
|
||||
}
|
||||
.boxed_local()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use actix_web::{test, web, App};
|
||||
use bytes::Bytes;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn cookie_session() {
|
||||
let mut app = test::init_service(
|
||||
App::new()
|
||||
.wrap(CookieSession::signed(&[0; 32]).secure(false))
|
||||
.service(web::resource("/").to(|ses: Session| async move {
|
||||
let _ = ses.set("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_rt::test]
|
||||
async fn private_cookie() {
|
||||
let mut app = test::init_service(
|
||||
App::new()
|
||||
.wrap(CookieSession::private(&[0; 32]).secure(false))
|
||||
.service(web::resource("/").to(|ses: Session| async move {
|
||||
let _ = ses.set("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_rt::test]
|
||||
async fn cookie_session_extractor() {
|
||||
let mut app = test::init_service(
|
||||
App::new()
|
||||
.wrap(CookieSession::signed(&[0; 32]).secure(false))
|
||||
.service(web::resource("/").to(|ses: Session| async move {
|
||||
let _ = ses.set("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_rt::test]
|
||||
async fn basics() {
|
||||
let mut 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.set("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::read_response(&mut app, request).await;
|
||||
assert_eq!(body, Bytes::from_static(b"counter: 100"));
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn prolong_expiration() {
|
||||
let mut 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.set("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");
|
||||
|
||||
actix_rt::time::delay_for(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");
|
||||
|
||||
assert!(expires_2 - expires_1 >= Duration::seconds(1));
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
463
actix-session/src/middleware.rs
Normal file
463
actix-session/src/middleware.rs
Normal file
@ -0,0 +1,463 @@
|
||||
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(())
|
||||
}
|
424
actix-session/src/session.rs
Normal file
424
actix-session/src/session.rs
Normal file
@ -0,0 +1,424 @@
|
||||
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())
|
||||
}
|
||||
}
|
38
actix-session/src/session_ext.rs
Normal file
38
actix-session/src/session_ext.rs
Normal file
@ -0,0 +1,38 @@
|
||||
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())
|
||||
}
|
||||
}
|
117
actix-session/src/storage/cookie.rs
Normal file
117
actix-session/src/storage/cookie.rs
Normal file
@ -0,0 +1,117 @@
|
||||
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(_),
|
||||
));
|
||||
}
|
||||
}
|
113
actix-session/src/storage/interface.rs
Normal file
113
actix-session/src/storage/interface.rs
Normal file
@ -0,0 +1,113 @@
|
||||
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()),
|
||||
}
|
||||
}
|
||||
}
|
19
actix-session/src/storage/mod.rs
Normal file
19
actix-session/src/storage/mod.rs
Normal file
@ -0,0 +1,19 @@
|
||||
//! 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,
|
||||
};
|
310
actix-session/src/storage/redis_actor.rs
Normal file
310
actix-session/src/storage/redis_actor.rs
Normal file
@ -0,0 +1,310 @@
|
||||
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());
|
||||
}
|
||||
}
|
476
actix-session/src/storage/redis_rs.rs
Normal file
476
actix-session/src/storage/redis_rs.rs
Normal file
@ -0,0 +1,476 @@
|
||||
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());
|
||||
}
|
||||
}
|
55
actix-session/src/storage/session_key.rs
Normal file
55
actix-session/src/storage/session_key.rs
Normal file
@ -0,0 +1,55 @@
|
||||
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())
|
||||
}
|
||||
}
|
13
actix-session/src/storage/utils.rs
Normal file
13
actix-session/src/storage/utils.rs
Normal file
@ -0,0 +1,13 @@
|
||||
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")
|
||||
}
|
56
actix-session/tests/middleware.rs
Normal file
56
actix-session/tests/middleware.rs
Normal file
@ -0,0 +1,56 @@
|
||||
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(())
|
||||
}
|
93
actix-session/tests/opaque_errors.rs
Normal file
93
actix-session/tests/opaque_errors.rs
Normal file
@ -0,0 +1,93 @@
|
||||
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"
|
||||
}
|
151
actix-session/tests/session.rs
Normal file
151
actix-session/tests/session.rs
Normal file
@ -0,0 +1,151 @@
|
||||
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);
|
||||
}
|
40
actix-settings/CHANGES.md
Normal file
40
actix-settings/CHANGES.md
Normal file
@ -0,0 +1,40 @@
|
||||
# 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>.
|
37
actix-settings/Cargo.toml
Normal file
37
actix-settings/Cargo.toml
Normal file
@ -0,0 +1,37 @@
|
||||
[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
|
1
actix-settings/LICENSE-APACHE
Symbolic link
1
actix-settings/LICENSE-APACHE
Symbolic link
@ -0,0 +1 @@
|
||||
../LICENSE-APACHE
|
1
actix-settings/LICENSE-MIT
Symbolic link
1
actix-settings/LICENSE-MIT
Symbolic link
@ -0,0 +1 @@
|
||||
../LICENSE-MIT
|
31
actix-settings/README.md
Normal file
31
actix-settings/README.md
Normal file
@ -0,0 +1,31 @@
|
||||
# actix-settings
|
||||
|
||||
> Easily manage Actix Web's settings from a TOML file and environment variables.
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
[](https://crates.io/crates/actix-settings)
|
||||
[](https://docs.rs/actix-settings/0.8.0)
|
||||

|
||||
[](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
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user