From df6bdea82bd232cc20a96dd9c4ed76d5a8887516 Mon Sep 17 00:00:00 2001 From: Luca P Date: Sat, 12 Mar 2022 19:03:55 +0000 Subject: [PATCH] Last part of chapter 10 - sessions, seed users and change password form --- Cargo.lock | 716 +++++++++++++++++- Cargo.toml | 11 +- configuration/base.yaml | 3 +- migrations/20220312175058_seed_user.sql | 6 + scripts/init_redis.sh | 20 + src/authentication/middleware.rs | 48 ++ src/authentication/mod.rs | 5 + .../password.rs} | 42 +- src/configuration.rs | 1 + src/lib.rs | 2 + src/main.rs | 2 +- src/routes/admin/dashboard.rs | 60 ++ src/routes/admin/logout.rs | 14 + src/routes/admin/mod.rs | 7 + src/routes/admin/password/get.rs | 61 ++ src/routes/admin/password/mod.rs | 4 + src/routes/admin/password/post.rs | 48 ++ src/routes/login/get.rs | 50 +- src/routes/login/post.rs | 37 +- src/routes/mod.rs | 2 + src/session_state.rs | 37 + src/startup.rs | 40 +- src/utils.rs | 15 + tests/api/admin_dashboard.rs | 43 ++ tests/api/change_password.rs | 141 ++++ tests/api/helpers.rs | 84 +- tests/api/login.rs | 25 + tests/api/main.rs | 3 + 28 files changed, 1433 insertions(+), 94 deletions(-) create mode 100644 migrations/20220312175058_seed_user.sql create mode 100755 scripts/init_redis.sh create mode 100644 src/authentication/middleware.rs create mode 100644 src/authentication/mod.rs rename src/{authentication.rs => authentication/password.rs} (68%) create mode 100644 src/routes/admin/dashboard.rs create mode 100644 src/routes/admin/logout.rs create mode 100644 src/routes/admin/mod.rs create mode 100644 src/routes/admin/password/get.rs create mode 100644 src/routes/admin/password/mod.rs create mode 100644 src/routes/admin/password/post.rs create mode 100644 src/session_state.rs create mode 100644 src/utils.rs create mode 100644 tests/api/admin_dashboard.rs create mode 100644 tests/api/change_password.rs create mode 100644 tests/api/login.rs diff --git a/Cargo.lock b/Cargo.lock index 1d9e524..0df2ac6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,29 @@ dependencies = [ "tokio-util 0.7.0", ] +[[package]] +name = "actix-files" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81bde9a79336aa51ebed236e91fc1a0528ff67cfdf4f68ca4c61ede9fd26fb5" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "askama_escape", + "bitflags", + "bytes", + "derive_more", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", +] + [[package]] name = "actix-http" version = "3.0.0" @@ -43,7 +66,7 @@ dependencies = [ "http", "httparse", "httpdate", - "itoa", + "itoa 1.0.1", "language-tags", "local-channel", "log", @@ -119,6 +142,25 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "actix-session" +version = "0.5.0" +source = "git+https://github.com/actix/actix-extras?branch=master#a086d30db225128d8aeee1799f2dce05393c2dce" +dependencies = [ + "actix-service", + "actix-utils", + "actix-web", + "anyhow", + "async-trait", + "derive_more", + "rand 0.8.5", + "redis", + "serde", + "serde_json", + "time 0.3.7", + "tracing", +] + [[package]] name = "actix-utils" version = "3.0.0" @@ -148,12 +190,12 @@ dependencies = [ "bytes", "bytestring", "cfg-if", - "cookie", + "cookie 0.16.0", "derive_more", "encoding_rs", "futures-core", "futures-util", - "itoa", + "itoa 1.0.1", "language-tags", "log", "mime", @@ -181,12 +223,96 @@ dependencies = [ "syn", ] +[[package]] +name = "actix-web-flash-messages" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38638194ec50d30af790d6a5472c16f2c62f08259ad1166e2de2a4411a5a2515" +dependencies = [ + "actix-web", + "anyhow", + "percent-encoding", + "serde", + "serde_json", + "thiserror", + "time 0.3.7", + "tokio", +] + +[[package]] +name = "actix-web-lab" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "277bee594fb4c95da23aee37864e78ff06b427b480ecca7c205c8b630a090acf" +dependencies = [ + "actix-files", + "actix-http", + "actix-router", + "actix-service", + "actix-utils", + "actix-web", + "ahash", + "bytes", + "csv", + "derive_more", + "digest 0.10.3", + "futures-core", + "futures-util", + "hmac 0.12.1", + "local-channel", + "log", + "matchit", + "mime", + "once_cell", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "subtle", + "tokio", +] + [[package]] name = "adler" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aead" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b613b8e1e3cf911a086f53f03bf286f52fd7a7258e4fa606f0ef220d39d8877" +dependencies = [ + "generic-array", +] + +[[package]] +name = "aes" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", + "opaque-debug", +] + +[[package]] +name = "aes-gcm" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df5f85a83a7d8b0442b6aa7b504b8212c1733da07b98aae43d4bc21b2cb3cdf6" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.7.6" @@ -237,6 +363,12 @@ version = "1.0.55" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "159bb86af3a200e19a068f4224eae4c8bb2d0fa054c7e5d1cacd5cef95e684cd" +[[package]] +name = "arc-swap" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d78ce20460b82d3fa150275ed9d55e21064fc7951177baacf86a145c4a4b1f" + [[package]] name = "argon2" version = "0.3.4" @@ -254,6 +386,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + [[package]] name = "assert-json-diff" version = "2.0.1" @@ -301,6 +439,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" +[[package]] +name = "base-x" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4521f3e3d031370679b3b140beb36dfe4801b09ac77e30c61941f97df3ef28b" + [[package]] name = "base64" version = "0.13.0" @@ -367,6 +511,18 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" +dependencies = [ + "lazy_static", + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.9.1" @@ -428,6 +584,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + [[package]] name = "claim" version = "0.5.0" @@ -437,6 +602,20 @@ dependencies = [ "autocfg", ] +[[package]] +name = "combine" +version = "4.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b727aacc797f9fc28e355d21f34709ac4fc9adecfe470ad07b8f4464f53062" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util 0.6.9", +] + [[package]] name = "concurrent-queue" version = "1.2.2" @@ -458,23 +637,79 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "const_fn" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbdcdcb6d86f71c5e97409ad45898af11cbc995b4ee8112d59095a28d376c935" + [[package]] name = "convert_case" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "cookie" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f1c7727e460397e56abc4bddc1d49e07a1ad78fc98eb2e1c8f032a58a2f80d" +dependencies = [ + "percent-encoding", + "time 0.2.27", + "version_check", +] + [[package]] name = "cookie" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94d4706de1b0fa5b132270cddffa8585166037822e260a944fe161acd137ca05" dependencies = [ + "aes-gcm", + "base64", + "hkdf", + "hmac 0.12.1", "percent-encoding", + "rand 0.8.5", + "sha2 0.10.2", + "subtle", "time 0.3.7", "version_check", ] +[[package]] +name = "cookie_store" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3f7034c0932dc36f5bd8ec37368d971346809435824f277cb3b8299fc56167c" +dependencies = [ + "cookie 0.15.1", + "idna", + "log", + "publicsuffix", + "serde", + "serde_json", + "time 0.2.27", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" + [[package]] name = "cpufeatures" version = "0.2.1" @@ -548,6 +783,37 @@ dependencies = [ "subtle", ] +[[package]] +name = "csv" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22813a6dc45b335f9bade10bf7271dc477e81113e89eb251a0bc2a8a81c536e1" +dependencies = [ + "bstr", + "csv-core", + "itoa 0.4.8", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctr" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" +dependencies = [ + "cipher", +] + [[package]] name = "deadpool" version = "0.9.2" @@ -575,7 +841,7 @@ dependencies = [ "convert_case", "proc-macro2", "quote", - "rustc_version", + "rustc_version 0.4.0", "syn", ] @@ -619,12 +885,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "discard" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0" + [[package]] name = "dotenv" version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "dtoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56899898ce76aaf4a0f24d914c97ea6ed976d42fec6ad33fcbb0a1103e07b2b0" + [[package]] name = "either" version = "1.6.1" @@ -701,6 +979,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.0.1" @@ -874,6 +1167,16 @@ dependencies = [ "wasi 0.10.0+wasi-snapshot-preview1", ] +[[package]] +name = "ghash" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1583cc1656d7839fd3732b80cf4f38850336cdb9b8ded1cd399ca62958de3c99" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "h2" version = "0.3.11" @@ -935,6 +1238,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hmac" version = "0.11.0" @@ -968,7 +1280,7 @@ checksum = "31f4c6746584866f0feabcc69893c5b51beef3831656a968ed7ae254cdc4fd03" dependencies = [ "bytes", "fnv", - "itoa", + "itoa 1.0.1", ] [[package]] @@ -982,6 +1294,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "http-types" version = "2.12.0" @@ -1030,7 +1348,7 @@ dependencies = [ "http-body", "httparse", "httpdate", - "itoa", + "itoa 1.0.1", "pin-project-lite", "socket2", "tokio", @@ -1103,6 +1421,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + [[package]] name = "itoa" version = "1.0.1" @@ -1224,6 +1548,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" +[[package]] +name = "matchit" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9376a4f0340565ad675d11fc1419227faf5f60cd7ac9cb2e7185a471f30af833" + [[package]] name = "md-5" version = "0.9.1" @@ -1247,6 +1577,16 @@ version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1285,6 +1625,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "native-tls" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48ba9f7719b5a0f42f338907614285fb5fd70e53858141f69898a1fb7203b24d" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nom" version = "5.1.2" @@ -1366,6 +1724,39 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +[[package]] +name = "openssl" +version = "0.10.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7ae222234c30df141154f159066c5093ff73b63204dcda7121eb082fc56a95" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-sys", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e46109c383602735fa0a2e48dd2b7c892b048e1bf69e5c3b1d804b7d9c203cb" +dependencies = [ + "autocfg", + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking" version = "2.0.0" @@ -1475,12 +1866,36 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58893f751c9b0412871a09abd62ecd2a00298c6c83befa223ef98c52aef40cbe" + +[[package]] +name = "polyval" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8419d2b623c7c0896ff2d5d96e2cb4ede590fed28fcc34934f4c33c036e620a1" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "ppv-lite86" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" +[[package]] +name = "proc-macro-hack" +version = "0.5.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbf0c48bc1d91375ae5c3cd81e3722dff1abcf81a30960240640d223f59fe0e5" + [[package]] name = "proc-macro2" version = "1.0.36" @@ -1490,6 +1905,24 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "psl-types" +version = "2.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8eda7c62d9ecaafdf8b62374c006de0adf61666ae96a96ba74a37134aa4e470" + +[[package]] +name = "publicsuffix" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "292972edad6bbecc137ab84c5e36421a4a6c979ea31d3cc73540dd04315b33e1" +dependencies = [ + "byteorder", + "hashbrown", + "idna", + "psl-types", +] + [[package]] name = "quickcheck" version = "0.9.2" @@ -1593,6 +2026,29 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "redis" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80b5f38d7f5a020856a0e16e40a9cfabf88ae8f0e4c2dcd8a3114c1e470852" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "combine", + "dtoa", + "futures", + "futures-util", + "itoa 0.4.8", + "native-tls", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-native-tls", + "tokio-util 0.6.9", + "url", +] + [[package]] name = "redox_syscall" version = "0.2.10" @@ -1638,6 +2094,15 @@ version = "0.6.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + [[package]] name = "reqwest" version = "0.11.9" @@ -1646,6 +2111,8 @@ checksum = "87f242f1488a539a79bac6dbe7c8609ae43b7914b7736210f239a37cccb32525" dependencies = [ "base64", "bytes", + "cookie 0.15.1", + "cookie_store", "encoding_rs", "futures-core", "futures-util", @@ -1661,6 +2128,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", + "proc-macro-hack", "rustls 0.20.4", "rustls-pemfile", "serde", @@ -1691,13 +2159,22 @@ dependencies = [ "winapi", ] +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver 0.9.0", +] + [[package]] name = "rustc_version" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" dependencies = [ - "semver", + "semver 1.0.6", ] [[package]] @@ -1740,6 +2217,16 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" +[[package]] +name = "schannel" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f05ba609c234e60bee0d547fe94a4c7e9da733d1c962cf6e59efa4cd9c8bc75" +dependencies = [ + "lazy_static", + "winapi", +] + [[package]] name = "scopeguard" version = "1.1.0" @@ -1776,12 +2263,50 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dc14f172faf8a0194a3aded622712b0de276821addc574fa54fc0a1167e10dc" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0160a13a177a45bfb43ce71c01580998474f556ad854dcbca936dd2841a5c556" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + [[package]] name = "semver" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4a3381e03edd24287172047536f20cabde766e2cd3e65e6b00fb3af51c4f38d" +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + [[package]] name = "serde" version = "1.0.136" @@ -1819,7 +2344,7 @@ version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e8d9fa5c3b304765ce1fd9c4c8a3de2c8db365a5b91be52f186efc675681d95" dependencies = [ - "itoa", + "itoa 1.0.1", "ryu", "serde", ] @@ -1842,7 +2367,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ "form_urlencoded", - "itoa", + "itoa 1.0.1", "ryu", "serde", ] @@ -1871,6 +2396,21 @@ dependencies = [ "digest 0.10.3", ] +[[package]] +name = "sha1" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1da05c97445caa12d05e848c4a4fcbbea29e748ac28f7e80e9b010392063770" +dependencies = [ + "sha1_smol", +] + +[[package]] +name = "sha1_smol" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" + [[package]] name = "sha2" version = "0.9.9" @@ -1987,7 +2527,7 @@ dependencies = [ "hex", "hmac 0.11.0", "indexmap", - "itoa", + "itoa 1.0.1", "libc", "log", "md-5", @@ -2048,12 +2588,70 @@ dependencies = [ "tokio-rustls 0.22.0", ] +[[package]] +name = "standback" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e113fb6f3de07a243d434a56ec6f186dfd51cb08448239fe7bcae73f87ff28ff" +dependencies = [ + "version_check", +] + [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stdweb" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d022496b16281348b52d0e30ae99e01a73d737b2f45d38fed4edf79f9325a1d5" +dependencies = [ + "discard", + "rustc_version 0.2.3", + "stdweb-derive", + "stdweb-internal-macros", + "stdweb-internal-runtime", + "wasm-bindgen", +] + +[[package]] +name = "stdweb-derive" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87a60a40fccc84bef0652345bbbbbe20a605bf5d0ce81719fc476f5c03b50ef" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_derive", + "syn", +] + +[[package]] +name = "stdweb-internal-macros" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fa5ff6ad0d98d1ffa8cb115892b6e69d67799f6763e162a1c9db421dc22e11" +dependencies = [ + "base-x", + "proc-macro2", + "quote", + "serde", + "serde_derive", + "serde_json", + "sha1", + "syn", +] + +[[package]] +name = "stdweb-internal-runtime" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0" + [[package]] name = "stringprep" version = "0.1.2" @@ -2081,6 +2679,20 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "tempfile" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" +dependencies = [ + "cfg-if", + "fastrand", + "libc", + "redox_syscall", + "remove_dir_all", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.30" @@ -2121,16 +2733,41 @@ dependencies = [ "winapi", ] +[[package]] +name = "time" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4752a97f8eebd6854ff91f1c1824cd6160626ac4bd44287f7f4ea2035a02a242" +dependencies = [ + "const_fn", + "libc", + "standback", + "stdweb", + "time-macros 0.1.1", + "version_check", + "winapi", +] + [[package]] name = "time" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "004cbc98f30fa233c61a38bc77e96a9106e65c88f2d3bef182ae952027e5753d" dependencies = [ - "itoa", + "itoa 1.0.1", "libc", "num_threads", - "time-macros", + "time-macros 0.2.3", +] + +[[package]] +name = "time-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957e9c6e26f12cb6d0dd7fc776bb67a706312e7299aed74c8dd5b17ebb27e2f1" +dependencies = [ + "proc-macro-hack", + "time-macros-impl", ] [[package]] @@ -2139,6 +2776,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25eb0ca3468fc0acc11828786797f6ef9aa1555e4a211a60d64cc8e4d1be47d6" +[[package]] +name = "time-macros-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c141a1b43194f3f56a1411225df8646c55781d5f26db825b3d98507eb482f" +dependencies = [ + "proc-macro-hack", + "proc-macro2", + "quote", + "standback", + "syn", +] + [[package]] name = "tinyvec" version = "1.5.1" @@ -2185,6 +2835,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d995660bd2b7f8c1568414c1126076c13fbb725c40112dc0120b78eb9b717b" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.22.0" @@ -2367,6 +3027,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" +[[package]] +name = "unicase" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.7" @@ -2400,6 +3069,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "universal-hash" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f214e8f697e925001e66ec2c6e37a4ef93f0f78c2eed7814394e10c62025b05" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "untrusted" version = "0.7.1" @@ -2432,6 +3111,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" dependencies = [ "getrandom 0.2.5", + "serde", ] [[package]] @@ -2466,6 +3146,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.4" @@ -2732,7 +3418,10 @@ dependencies = [ name = "zero2prod" version = "0.1.0" dependencies = [ + "actix-session", "actix-web", + "actix-web-flash-messages", + "actix-web-lab", "anyhow", "argon2", "base64", @@ -2740,8 +3429,6 @@ dependencies = [ "claim", "config", "fake", - "hex", - "hmac 0.12.1", "htmlescape", "linkify", "log", @@ -2754,7 +3441,6 @@ dependencies = [ "serde", "serde-aux", "serde_json", - "sha2 0.10.2", "sqlx", "thiserror", "tokio", diff --git a/Cargo.toml b/Cargo.toml index b6cd6e0..537d57e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,9 +18,9 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread"] } serde = "1.0.115" config = { version = "0.11", default-features = false, features = ["yaml"] } sqlx = { version = "0.5.5", default-features = false, features = [ "runtime-actix-rustls", "macros", "postgres", "uuid", "chrono", "migrate", "offline"] } -uuid = { version = "0.8.1", features = ["v4"] } +uuid = { version = "0.8.1", features = ["v4", "serde"] } chrono = "0.4.15" -reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls", "cookies"] } log = "0.4" tracing = "0.1.19" tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] } @@ -38,9 +38,10 @@ tracing-actix-web = "0.5" secrecy = { version = "0.8", features = ["serde"] } urlencoding = "2" htmlescape = "0.3" -hmac = { version = "0.12", features = ["std"] } -sha2 = "0.10" -hex = "0.4" +actix-web-flash-messages = { version = "0.3", features = ["cookies"] } +actix-session = { git = "https://github.com/actix/actix-extras", branch = "master", features = ["redis-rs-tls-session"] } +serde_json = "1" +actix-web-lab = "0.15" [dev-dependencies] once_cell = "1.7.2" diff --git a/configuration/base.yaml b/configuration/base.yaml index 9608b08..861bf34 100644 --- a/configuration/base.yaml +++ b/configuration/base.yaml @@ -13,4 +13,5 @@ email_client: base_url: "localhost" sender_email: "test@gmail.com" authorization_token: "my-secret-token" - timeout_milliseconds: 10000 \ No newline at end of file + timeout_milliseconds: 10000 +redis_uri: "redis://127.0.0.1:6379" \ No newline at end of file diff --git a/migrations/20220312175058_seed_user.sql b/migrations/20220312175058_seed_user.sql new file mode 100644 index 0000000..a9f1ea6 --- /dev/null +++ b/migrations/20220312175058_seed_user.sql @@ -0,0 +1,6 @@ +INSERT INTO users (user_id, username, password_hash) +VALUES ( + 'ddf8994f-d522-4659-8d02-c1d479057be6', + 'admin', + '$argon2id$v=19$m=15000,t=2,p=1$OEx/rcq+3ts//WUDzGNl2g$Am8UFBA4w5NJEmAtquGvBmAlu92q/VQcaoL5AyJPfc8' +); \ No newline at end of file diff --git a/scripts/init_redis.sh b/scripts/init_redis.sh new file mode 100755 index 0000000..972ced5 --- /dev/null +++ b/scripts/init_redis.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -x +set -eo pipefail + +# if a redis container is running, print instructions to kill it and exit +RUNNING_CONTAINER=$(docker ps --filter 'name=redis' --format '{{.ID}}') +if [[ -n $RUNNING_CONTAINER ]]; then + echo >&2 "there is a redis container already running, kill it with" + echo >&2 " docker kill ${RUNNING_CONTAINER}" + exit 1 +fi + +# Launch Redis using Docker +docker run \ + -p "6379:6379" \ + -d \ + --name "redis_$(date '+%s')" \ + redis:6 + +>&2 echo "Redis is ready to go!" \ No newline at end of file diff --git a/src/authentication/middleware.rs b/src/authentication/middleware.rs new file mode 100644 index 0000000..2609e5a --- /dev/null +++ b/src/authentication/middleware.rs @@ -0,0 +1,48 @@ +use crate::session_state::TypedSession; +use crate::utils::{e500, see_other}; +use actix_web::body::MessageBody; +use actix_web::dev::{ServiceRequest, ServiceResponse}; +use actix_web::error::InternalError; +use actix_web::{FromRequest, HttpMessage}; +use actix_web_lab::middleware::Next; +use std::ops::Deref; +use uuid::Uuid; + +#[derive(Copy, Clone, Debug)] +pub struct UserId(Uuid); + +impl std::fmt::Display for UserId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl Deref for UserId { + type Target = Uuid; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +pub async fn reject_anonymous_users( + mut req: ServiceRequest, + next: Next, +) -> Result, actix_web::Error> { + let session = { + let (http_request, payload) = req.parts_mut(); + TypedSession::from_request(http_request, payload).await + }?; + + match session.get_user_id().map_err(e500)? { + Some(user_id) => { + req.extensions_mut().insert(UserId(user_id)); + next.call(req).await + } + None => { + let response = see_other("/login"); + let e = anyhow::anyhow!("The user has not logged in"); + Err(InternalError::from_response(e, response).into()) + } + } +} diff --git a/src/authentication/mod.rs b/src/authentication/mod.rs new file mode 100644 index 0000000..d7d7966 --- /dev/null +++ b/src/authentication/mod.rs @@ -0,0 +1,5 @@ +mod middleware; +mod password; +pub use middleware::reject_anonymous_users; +pub use middleware::UserId; +pub use password::{change_password, validate_credentials, AuthError, Credentials}; diff --git a/src/authentication.rs b/src/authentication/password.rs similarity index 68% rename from src/authentication.rs rename to src/authentication/password.rs index f08498f..5c12360 100644 --- a/src/authentication.rs +++ b/src/authentication/password.rs @@ -1,10 +1,10 @@ +use crate::telemetry::spawn_blocking_with_tracing; use anyhow::Context; -use argon2::{Argon2, PasswordHash, PasswordVerifier}; +use argon2::password_hash::SaltString; +use argon2::{Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version}; use secrecy::{ExposeSecret, Secret}; use sqlx::PgPool; -use crate::telemetry::spawn_blocking_with_tracing; - #[derive(thiserror::Error, Debug)] pub enum AuthError { #[error("Invalid credentials.")] @@ -88,3 +88,39 @@ fn verify_password_hash( .context("Invalid password.") .map_err(AuthError::InvalidCredentials) } + +#[tracing::instrument(name = "Change password", skip(password, pool))] +pub async fn change_password( + user_id: uuid::Uuid, + password: Secret, + pool: &PgPool, +) -> Result<(), anyhow::Error> { + let password_hash = spawn_blocking_with_tracing(move || compute_password_hash(password)) + .await? + .context("Failed to hash password")?; + sqlx::query!( + r#" + UPDATE users + SET password_hash = $1 + WHERE user_id = $2 + "#, + password_hash.expose_secret(), + user_id + ) + .execute(pool) + .await + .context("Failed to change user's password in the database.")?; + Ok(()) +} + +fn compute_password_hash(password: Secret) -> Result, anyhow::Error> { + let salt = SaltString::generate(&mut rand::thread_rng()); + let password_hash = Argon2::new( + Algorithm::Argon2id, + Version::V0x13, + Params::new(15000, 2, 1, None).unwrap(), + ) + .hash_password(password.expose_secret().as_bytes(), &salt)? + .to_string(); + Ok(Secret::new(password_hash)) +} diff --git a/src/configuration.rs b/src/configuration.rs index ab41bbe..e5dcb85 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -10,6 +10,7 @@ pub struct Settings { pub database: DatabaseSettings, pub application: ApplicationSettings, pub email_client: EmailClientSettings, + pub redis_uri: Secret, } #[derive(serde::Deserialize, Clone)] diff --git a/src/lib.rs b/src/lib.rs index 1bb9b5c..f469562 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,5 +3,7 @@ pub mod configuration; pub mod domain; pub mod email_client; pub mod routes; +pub mod session_state; pub mod startup; pub mod telemetry; +pub mod utils; diff --git a/src/main.rs b/src/main.rs index 6ec5821..786f6c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,7 @@ use zero2prod::startup::Application; use zero2prod::telemetry::{get_subscriber, init_subscriber}; #[tokio::main] -async fn main() -> std::io::Result<()> { +async fn main() -> anyhow::Result<()> { let subscriber = get_subscriber("zero2prod".into(), "info".into(), std::io::stdout); init_subscriber(subscriber); diff --git a/src/routes/admin/dashboard.rs b/src/routes/admin/dashboard.rs new file mode 100644 index 0000000..acffecb --- /dev/null +++ b/src/routes/admin/dashboard.rs @@ -0,0 +1,60 @@ +use crate::session_state::TypedSession; +use crate::utils::e500; +use actix_web::http::header::LOCATION; +use actix_web::{http::header::ContentType, web, HttpResponse}; +use anyhow::Context; +use sqlx::PgPool; +use uuid::Uuid; + +pub async fn admin_dashboard( + session: TypedSession, + pool: web::Data, +) -> Result { + let username = if let Some(user_id) = session.get_user_id().map_err(e500)? { + get_username(user_id, &pool).await.map_err(e500)? + } else { + return Ok(HttpResponse::SeeOther() + .insert_header((LOCATION, "/login")) + .finish()); + }; + Ok(HttpResponse::Ok() + .content_type(ContentType::html()) + .body(format!( + r#" + + + + Admin dashboard + + +

Welcome {username}!

+

Available actions:

+
    +
  1. Change password
  2. +
  3. + Logout + +
  4. +
+ +"#, + ))) +} + +#[tracing::instrument(name = "Get username", skip(pool))] +pub async fn get_username(user_id: Uuid, pool: &PgPool) -> Result { + let row = sqlx::query!( + r#" + SELECT username + FROM users + WHERE user_id = $1 + "#, + user_id, + ) + .fetch_one(pool) + .await + .context("Failed to perform a query to retrieve a username.")?; + Ok(row.username) +} diff --git a/src/routes/admin/logout.rs b/src/routes/admin/logout.rs new file mode 100644 index 0000000..1f0b70b --- /dev/null +++ b/src/routes/admin/logout.rs @@ -0,0 +1,14 @@ +use crate::session_state::TypedSession; +use crate::utils::{e500, see_other}; +use actix_web::HttpResponse; +use actix_web_flash_messages::FlashMessage; + +pub async fn log_out(session: TypedSession) -> Result { + if session.get_user_id().map_err(e500)?.is_none() { + Ok(see_other("/login")) + } else { + session.log_out(); + FlashMessage::info("You have successfully logged out.").send(); + Ok(see_other("/login")) + } +} diff --git a/src/routes/admin/mod.rs b/src/routes/admin/mod.rs new file mode 100644 index 0000000..857ea51 --- /dev/null +++ b/src/routes/admin/mod.rs @@ -0,0 +1,7 @@ +mod dashboard; +mod logout; +mod password; + +pub use dashboard::admin_dashboard; +pub use logout::log_out; +pub use password::*; diff --git a/src/routes/admin/password/get.rs b/src/routes/admin/password/get.rs new file mode 100644 index 0000000..180d938 --- /dev/null +++ b/src/routes/admin/password/get.rs @@ -0,0 +1,61 @@ +use crate::session_state::TypedSession; +use crate::utils::{e500, see_other}; +use actix_web::http::header::ContentType; +use actix_web::HttpResponse; +use actix_web_flash_messages::IncomingFlashMessages; +use std::fmt::Write; + +pub async fn change_password_form( + session: TypedSession, + flash_messages: IncomingFlashMessages, +) -> Result { + if session.get_user_id().map_err(e500)?.is_none() { + return Ok(see_other("/login")); + }; + let mut msg_html = String::new(); + for m in flash_messages.iter() { + writeln!(msg_html, "

{}

", m.content()).unwrap(); + } + Ok(HttpResponse::Ok() + .content_type(ContentType::html()) + .body(format!( + r#" + + + + Change Password + + + {msg_html} +
+ +
+ +
+ +
+ +
+

<- Back

+ +"#, + ))) +} diff --git a/src/routes/admin/password/mod.rs b/src/routes/admin/password/mod.rs new file mode 100644 index 0000000..75eb960 --- /dev/null +++ b/src/routes/admin/password/mod.rs @@ -0,0 +1,4 @@ +mod get; +pub use get::change_password_form; +mod post; +pub use post::change_password; diff --git a/src/routes/admin/password/post.rs b/src/routes/admin/password/post.rs new file mode 100644 index 0000000..2f97a29 --- /dev/null +++ b/src/routes/admin/password/post.rs @@ -0,0 +1,48 @@ +use crate::authentication::{validate_credentials, AuthError, Credentials, UserId}; +use crate::routes::admin::dashboard::get_username; +use crate::utils::{e500, see_other}; +use actix_web::{web, HttpResponse}; +use actix_web_flash_messages::FlashMessage; +use secrecy::{ExposeSecret, Secret}; +use sqlx::PgPool; + +#[derive(serde::Deserialize)] +pub struct FormData { + current_password: Secret, + new_password: Secret, + new_password_check: Secret, +} + +pub async fn change_password( + form: web::Form, + pool: web::Data, + user_id: web::ReqData, +) -> Result { + let user_id = user_id.into_inner(); + if form.new_password.expose_secret() != form.new_password_check.expose_secret() { + FlashMessage::error( + "You entered two different new passwords - the field values must match.", + ) + .send(); + return Ok(see_other("/admin/password")); + } + let username = get_username(*user_id, &pool).await.map_err(e500)?; + let credentials = Credentials { + username, + password: form.0.current_password, + }; + if let Err(e) = validate_credentials(credentials, &pool).await { + return match e { + AuthError::InvalidCredentials(_) => { + FlashMessage::error("The current password is incorrect.").send(); + Ok(see_other("/admin/password")) + } + AuthError::UnexpectedError(_) => Err(e500(e)), + }; + } + crate::authentication::change_password(*user_id, form.0.new_password, &pool) + .await + .map_err(e500)?; + FlashMessage::error("Your password has been changed.").send(); + Ok(see_other("/admin/password")) +} diff --git a/src/routes/login/get.rs b/src/routes/login/get.rs index b8f0c10..218cbcf 100644 --- a/src/routes/login/get.rs +++ b/src/routes/login/get.rs @@ -1,48 +1,12 @@ -use crate::startup::HmacSecret; -use actix_web::{http::header::ContentType, web, HttpResponse}; -use hmac::{Hmac, Mac}; -use secrecy::ExposeSecret; +use actix_web::{http::header::ContentType, HttpResponse}; +use actix_web_flash_messages::{IncomingFlashMessages}; +use std::fmt::Write; -#[derive(serde::Deserialize)] -pub struct QueryParams { - error: String, - tag: String, -} - -impl QueryParams { - fn verify(self, secret: &HmacSecret) -> Result { - let tag = hex::decode(self.tag)?; - let query_string = format!("error={}", urlencoding::Encoded::new(&self.error)); - - let mut mac = - Hmac::::new_from_slice(secret.0.expose_secret().as_bytes()).unwrap(); - mac.update(query_string.as_bytes()); - mac.verify_slice(&tag)?; - - Ok(self.error) +pub async fn login_form(flash_messages: IncomingFlashMessages) -> HttpResponse { + let mut error_html = String::new(); + for m in flash_messages.iter() { + writeln!(error_html, "

{}

", m.content()).unwrap(); } -} - -pub async fn login_form( - query: Option>, - secret: web::Data, -) -> HttpResponse { - let error_html = match query { - None => "".into(), - Some(query) => match query.0.verify(&secret) { - Ok(error) => { - format!("

{}

", htmlescape::encode_minimal(&error)) - } - Err(e) => { - tracing::warn!( - error.message = %e, - error.cause_chain = ?e, - "Failed to verify query parameters using the HMAC tag" - ); - "".into() - } - }, - }; HttpResponse::Ok() .content_type(ContentType::html()) .body(format!( diff --git a/src/routes/login/post.rs b/src/routes/login/post.rs index 3904bb9..1f96f89 100644 --- a/src/routes/login/post.rs +++ b/src/routes/login/post.rs @@ -1,13 +1,13 @@ use crate::authentication::AuthError; use crate::authentication::{validate_credentials, Credentials}; use crate::routes::error_chain_fmt; -use crate::startup::HmacSecret; +use crate::session_state::TypedSession; use actix_web::error::InternalError; use actix_web::http::header::LOCATION; use actix_web::web; use actix_web::HttpResponse; -use hmac::{Hmac, Mac}; -use secrecy::{ExposeSecret, Secret}; +use actix_web_flash_messages::FlashMessage; +use secrecy::Secret; use sqlx::PgPool; #[derive(serde::Deserialize)] @@ -17,14 +17,14 @@ pub struct FormData { } #[tracing::instrument( - skip(form, pool, secret), + skip(form, pool, session), fields(username=tracing::field::Empty, user_id=tracing::field::Empty) )] // We are now injecting `PgPool` to retrieve stored credentials from the database pub async fn login( form: web::Form, pool: web::Data, - secret: web::Data, + session: TypedSession, ) -> Result> { let credentials = Credentials { username: form.0.username, @@ -34,8 +34,12 @@ pub async fn login( match validate_credentials(credentials, &pool).await { Ok(user_id) => { tracing::Span::current().record("user_id", &tracing::field::display(&user_id)); + session.renew(); + session + .insert_user_id(user_id) + .map_err(|e| login_redirect(LoginError::UnexpectedError(e.into())))?; Ok(HttpResponse::SeeOther() - .insert_header((LOCATION, "/")) + .insert_header((LOCATION, "/admin/dashboard")) .finish()) } Err(e) => { @@ -43,22 +47,19 @@ pub async fn login( AuthError::InvalidCredentials(_) => LoginError::AuthError(e.into()), AuthError::UnexpectedError(_) => LoginError::UnexpectedError(e.into()), }; - let query_string = format!("error={}", urlencoding::Encoded::new(e.to_string())); - let hmac_tag = { - let mut mac = - Hmac::::new_from_slice(secret.0.expose_secret().as_bytes()) - .unwrap(); - mac.update(query_string.as_bytes()); - mac.finalize().into_bytes() - }; - let response = HttpResponse::SeeOther() - .insert_header((LOCATION, format!("/login?{query_string}&tag={hmac_tag:x}"))) - .finish(); - Err(InternalError::from_response(e, response)) + Err(login_redirect(e)) } } } +fn login_redirect(e: LoginError) -> InternalError { + FlashMessage::error(e.to_string()).send(); + let response = HttpResponse::SeeOther() + .insert_header((LOCATION, "/login")) + .finish(); + InternalError::from_response(e, response) +} + #[derive(thiserror::Error)] pub enum LoginError { #[error("Authentication failed")] diff --git a/src/routes/mod.rs b/src/routes/mod.rs index b0d8892..671805f 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,3 +1,4 @@ +mod admin; mod health_check; mod home; mod login; @@ -5,6 +6,7 @@ mod newsletters; mod subscriptions; mod subscriptions_confirm; +pub use admin::*; pub use health_check::*; pub use home::*; pub use login::*; diff --git a/src/session_state.rs b/src/session_state.rs new file mode 100644 index 0000000..2923f23 --- /dev/null +++ b/src/session_state.rs @@ -0,0 +1,37 @@ +use actix_session::Session; +use actix_session::SessionExt; +use actix_web::dev::Payload; +use actix_web::{FromRequest, HttpRequest}; +use std::future::{ready, Ready}; +use uuid::Uuid; + +pub struct TypedSession(Session); + +impl TypedSession { + const USER_ID_KEY: &'static str = "user_id"; + + pub fn renew(&self) { + self.0.renew(); + } + + pub fn insert_user_id(&self, user_id: Uuid) -> Result<(), serde_json::Error> { + self.0.insert(Self::USER_ID_KEY, user_id) + } + + pub fn get_user_id(&self) -> Result, serde_json::Error> { + self.0.get(Self::USER_ID_KEY) + } + + pub fn log_out(self) { + self.0.purge() + } +} + +impl FromRequest for TypedSession { + type Error = ::Error; + type Future = Ready>; + + fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future { + ready(Ok(TypedSession(req.get_session()))) + } +} diff --git a/src/startup.rs b/src/startup.rs index d29ec2e..8669a17 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -1,12 +1,20 @@ +use crate::authentication::reject_anonymous_users; use crate::configuration::{DatabaseSettings, Settings}; use crate::email_client::EmailClient; use crate::routes::{ - confirm, health_check, home, login, login_form, publish_newsletter, subscribe, + admin_dashboard, change_password, change_password_form, confirm, health_check, home, log_out, + login, login_form, publish_newsletter, subscribe, }; +use actix_session::storage::RedisSessionStore; +use actix_session::SessionMiddleware; +use actix_web::cookie::Key; use actix_web::dev::Server; use actix_web::web::Data; use actix_web::{web, App, HttpServer}; -use secrecy::Secret; +use actix_web_flash_messages::storage::CookieMessageStore; +use actix_web_flash_messages::FlashMessagesFramework; +use actix_web_lab::middleware::from_fn; +use secrecy::{ExposeSecret, Secret}; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; use std::net::TcpListener; @@ -18,7 +26,7 @@ pub struct Application { } impl Application { - pub async fn build(configuration: Settings) -> Result { + pub async fn build(configuration: Settings) -> Result { let connection_pool = get_connection_pool(&configuration.database) .await .expect("Failed to connect to Postgres."); @@ -47,7 +55,9 @@ impl Application { email_client, configuration.application.base_url, configuration.application.hmac_secret, - )?; + configuration.redis_uri, + ) + .await?; Ok(Self { port, server }) } @@ -70,20 +80,38 @@ pub async fn get_connection_pool(configuration: &DatabaseSettings) -> Result, -) -> Result { + redis_uri: Secret, +) -> Result { let db_pool = Data::new(db_pool); let email_client = Data::new(email_client); let base_url = Data::new(ApplicationBaseUrl(base_url)); + let secret_key = Key::from(hmac_secret.expose_secret().as_bytes()); + let message_store = CookieMessageStore::builder(secret_key.clone()).build(); + let message_framework = FlashMessagesFramework::builder(message_store).build(); + let redis_store = RedisSessionStore::new(redis_uri.expose_secret()).await?; let server = HttpServer::new(move || { App::new() + .wrap(message_framework.clone()) + .wrap(SessionMiddleware::new( + redis_store.clone(), + secret_key.clone(), + )) .wrap(TracingLogger::default()) .route("/", web::get().to(home)) + .service( + web::scope("/admin") + .wrap(from_fn(reject_anonymous_users)) + .route("/dashboard", web::get().to(admin_dashboard)) + .route("/password", web::get().to(change_password_form)) + .route("/password", web::post().to(change_password)) + .route("/logout", web::post().to(log_out)), + ) .route("/login", web::get().to(login_form)) .route("/login", web::post().to(login)) .route("/health_check", web::get().to(health_check)) diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..b606c98 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,15 @@ +use actix_web::http::header::LOCATION; +use actix_web::HttpResponse; + +// Return an opaque 500 while preserving the error root's cause for logging. +pub fn e500(e: T) -> actix_web::Error +where + T: std::fmt::Debug + std::fmt::Display + 'static, +{ + actix_web::error::ErrorInternalServerError(e) +} +pub fn see_other(location: &str) -> HttpResponse { + HttpResponse::SeeOther() + .insert_header((LOCATION, location)) + .finish() +} diff --git a/tests/api/admin_dashboard.rs b/tests/api/admin_dashboard.rs new file mode 100644 index 0000000..c1b3f23 --- /dev/null +++ b/tests/api/admin_dashboard.rs @@ -0,0 +1,43 @@ +use crate::helpers::{assert_is_redirect_to, spawn_app}; + +#[tokio::test] +async fn you_must_be_logged_in_to_access_the_admin_dashboard() { + // Arrange + let app = spawn_app().await; + + // Act + let response = app.get_admin_dashboard().await; + + // Assert + assert_is_redirect_to(&response, "/login"); +} + +#[tokio::test] +async fn logout_clears_session_state() { + // Arrange + let app = spawn_app().await; + + // Act - Part 1 - Login + let login_body = serde_json::json!({ + "username": &app.test_user.username, + "password": &app.test_user.password + }); + let response = app.post_login(&login_body).await; + assert_is_redirect_to(&response, "/admin/dashboard"); + + // Act - Part 2 - Follow the redirect + let html_page = app.get_admin_dashboard_html().await; + assert!(html_page.contains(&format!("Welcome {}", app.test_user.username))); + + // Act - Part 3 - Logout + let response = app.post_logout().await; + assert_is_redirect_to(&response, "/login"); + + // Act - Part 4 - Follow the redirect + let html_page = app.get_login_html().await; + assert!(html_page.contains(r#"

You have successfully logged out.

"#)); + + // Act - Part 5 - Attempt to load admin panel + let response = app.get_admin_dashboard().await; + assert_is_redirect_to(&response, "/login"); +} diff --git a/tests/api/change_password.rs b/tests/api/change_password.rs new file mode 100644 index 0000000..8db6cd8 --- /dev/null +++ b/tests/api/change_password.rs @@ -0,0 +1,141 @@ +use crate::helpers::{assert_is_redirect_to, spawn_app}; +use uuid::Uuid; + +#[tokio::test] +async fn you_must_be_logged_in_to_see_the_change_password_form() { + // Arrange + let app = spawn_app().await; + + // Act + let response = app.get_change_password().await; + + // Assert + assert_is_redirect_to(&response, "/login"); +} + +#[tokio::test] +async fn you_must_be_logged_in_to_change_your_password() { + // Arrange + let app = spawn_app().await; + let new_password = Uuid::new_v4().to_string(); + + // Act + let response = app + .post_change_password(&serde_json::json!({ + "current_password": Uuid::new_v4().to_string(), + "new_password": &new_password, + "new_password_check": &new_password, + })) + .await; + + // Assert + assert_is_redirect_to(&response, "/login"); +} + +#[tokio::test] +async fn new_password_fields_must_match() { + // Arrange + let app = spawn_app().await; + let new_password = Uuid::new_v4().to_string(); + let another_new_password = Uuid::new_v4().to_string(); + + // Act - Part 1 - Login + app.post_login(&serde_json::json!({ + "username": &app.test_user.username, + "password": &app.test_user.password + })) + .await; + + // Act - Part 2 - Try to change password + let response = app + .post_change_password(&serde_json::json!({ + "current_password": &app.test_user.password, + "new_password": &new_password, + "new_password_check": &another_new_password, + })) + .await; + assert_is_redirect_to(&response, "/admin/password"); + + // Act - Part 3 - Follow the redirect + let html_page = app.get_change_password_html().await; + assert!(html_page.contains( + "

You entered two different new passwords - \ + the field values must match.

" + )); +} + +#[tokio::test] +async fn current_password_must_be_valid() { + // Arrange + let app = spawn_app().await; + let new_password = Uuid::new_v4().to_string(); + let wrong_password = Uuid::new_v4().to_string(); + + // Act - Part 1 - Login + app.post_login(&serde_json::json!({ + "username": &app.test_user.username, + "password": &app.test_user.password + })) + .await; + + // Act - Part 2 - Try to change password + let response = app + .post_change_password(&serde_json::json!({ + "current_password": &wrong_password, + "new_password": &new_password, + "new_password_check": &new_password, + })) + .await; + + // Assert + assert_is_redirect_to(&response, "/admin/password"); + + // Act - Part 3 - Follow the redirect + let html_page = app.get_change_password_html().await; + assert!(html_page.contains("

The current password is incorrect.

")); +} + +#[tokio::test] +async fn changing_password_works() { + // Arrange + let app = spawn_app().await; + let new_password = Uuid::new_v4().to_string(); + + // Act - Part 1 - Login + let login_body = serde_json::json!({ + "username": &app.test_user.username, + "password": &app.test_user.password + }); + let response = app.post_login(&login_body).await; + assert_is_redirect_to(&response, "/admin/dashboard"); + + // Act - Part 2 - Change password + let response = app + .post_change_password(&serde_json::json!({ + "current_password": &app.test_user.password, + "new_password": &new_password, + "new_password_check": &new_password, + })) + .await; + assert_is_redirect_to(&response, "/admin/password"); + + // Act - Part 3 - Follow the redirect + let html_page = app.get_change_password_html().await; + assert!(html_page.contains("

Your password has been changed.

")); + + // Act - Part 4 - Logout + let response = app.post_logout().await; + assert_is_redirect_to(&response, "/login"); + + // Act - Part 5 - Follow the redirect + let html_page = app.get_login_html().await; + assert!(html_page.contains("

You have successfully logged out.

")); + + // Act - Part 6 - Login using the new password + let login_body = serde_json::json!({ + "username": &app.test_user.username, + "password": &new_password + }); + let response = app.post_login(&login_body).await; + assert_is_redirect_to(&response, "/admin/dashboard"); +} diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index d7f140f..09643ba 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -27,6 +27,7 @@ pub struct TestApp { pub db_pool: PgPool, pub email_server: MockServer, pub test_user: TestUser, + pub api_client: reqwest::Client, } /// Confirmation links embedded in the request to the email API. @@ -37,7 +38,7 @@ pub struct ConfirmationLinks { impl TestApp { pub async fn post_subscriptions(&self, body: String) -> reqwest::Response { - reqwest::Client::new() + self.api_client .post(&format!("{}/subscriptions", &self.address)) .header("Content-Type", "application/x-www-form-urlencoded") .body(body) @@ -47,7 +48,7 @@ impl TestApp { } pub async fn post_newsletters(&self, body: serde_json::Value) -> reqwest::Response { - reqwest::Client::new() + self.api_client .post(&format!("{}/newsletters", &self.address)) .basic_auth(&self.test_user.username, Some(&self.test_user.password)) .json(&body) @@ -56,6 +57,73 @@ impl TestApp { .expect("Failed to execute request.") } + pub async fn post_login(&self, body: &Body) -> reqwest::Response + where + Body: serde::Serialize, + { + self.api_client + .post(&format!("{}/login", &self.address)) + .form(body) + .send() + .await + .expect("Failed to execute request.") + } + + pub async fn get_login_html(&self) -> String { + self.api_client + .get(&format!("{}/login", &self.address)) + .send() + .await + .expect("Failed to execute request.") + .text() + .await + .unwrap() + } + + pub async fn get_admin_dashboard(&self) -> reqwest::Response { + self.api_client + .get(&format!("{}/admin/dashboard", &self.address)) + .send() + .await + .expect("Failed to execute request.") + } + + pub async fn get_admin_dashboard_html(&self) -> String { + self.get_admin_dashboard().await.text().await.unwrap() + } + + pub async fn get_change_password(&self) -> reqwest::Response { + self.api_client + .get(&format!("{}/admin/password", &self.address)) + .send() + .await + .expect("Failed to execute request.") + } + + pub async fn get_change_password_html(&self) -> String { + self.get_change_password().await.text().await.unwrap() + } + + pub async fn post_logout(&self) -> reqwest::Response { + self.api_client + .post(&format!("{}/admin/logout", &self.address)) + .send() + .await + .expect("Failed to execute request.") + } + + pub async fn post_change_password(&self, body: &Body) -> reqwest::Response + where + Body: serde::Serialize, + { + self.api_client + .post(&format!("{}/admin/password", &self.address)) + .form(body) + .send() + .await + .expect("Failed to execute request.") + } + /// Extract the confirmation links embedded in the request to the email API. pub fn get_confirmation_links(&self, email_request: &wiremock::Request) -> ConfirmationLinks { let body: serde_json::Value = serde_json::from_slice(&email_request.body).unwrap(); @@ -109,6 +177,12 @@ pub async fn spawn_app() -> TestApp { let application_port = application.port(); let _ = tokio::spawn(application.run_until_stopped()); + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .cookie_store(true) + .build() + .unwrap(); + let test_app = TestApp { address: format!("http://localhost:{}", application_port), port: application_port, @@ -117,6 +191,7 @@ pub async fn spawn_app() -> TestApp { .expect("Failed to connect to the database"), email_server, test_user: TestUser::generate(), + api_client: client, }; test_app.test_user.store(&test_app.db_pool).await; @@ -184,3 +259,8 @@ impl TestUser { .expect("Failed to store test user."); } } + +pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) { + assert_eq!(response.status().as_u16(), 303); + assert_eq!(response.headers().get("Location").unwrap(), location); +} diff --git a/tests/api/login.rs b/tests/api/login.rs new file mode 100644 index 0000000..19e8caf --- /dev/null +++ b/tests/api/login.rs @@ -0,0 +1,25 @@ +use crate::helpers::{assert_is_redirect_to, spawn_app}; + +#[tokio::test] +async fn an_error_flash_message_is_set_on_failure() { + // Arrange + let app = spawn_app().await; + + // Act + let login_body = serde_json::json!({ + "username": "random-username", + "password": "random-password" + }); + let response = app.post_login(&login_body).await; + + // Assert + assert_is_redirect_to(&response, "/login"); + + // Act - Part 2 - Follow the redirect + let html_page = app.get_login_html().await; + assert!(html_page.contains("

Authentication failed

")); + + // Act - Part 3 - Reload the login page + let html_page = app.get_login_html().await; + assert!(!html_page.contains("

Authentication failed

")); +} diff --git a/tests/api/main.rs b/tests/api/main.rs index 8a7a20c..2b82533 100644 --- a/tests/api/main.rs +++ b/tests/api/main.rs @@ -1,5 +1,8 @@ +mod admin_dashboard; +mod change_password; mod health_check; mod helpers; +mod login; mod newsletter; mod subscriptions; mod subscriptions_confirm;