From e2bd270d7c52fcf9ad8c12509e82d8b068df6915 Mon Sep 17 00:00:00 2001 From: Ben Sherriff Date: Fri, 17 Nov 2023 09:07:30 -0500 Subject: [PATCH] Added user auth --- service/.env.TEMPLATE | 18 +- service/Cargo.lock | 612 ++++++++++++------ service/Cargo.toml | 17 +- service/Makefile | 4 + service/docker-compose.yml | 21 +- .../down.sql | 0 .../up.sql | 0 .../down.sql | 0 .../up.sql | 0 service/migrations/000002_create_users/up.sql | 11 - .../down.sql | 0 service/migrations/000002_users/up.sql | 11 + service/src/airports/model.rs | 2 +- service/src/auth/mod.rs | 97 +++ service/src/auth/model.rs | 205 ++++++ service/src/auth/routes.rs | 367 +++++++++++ service/src/{db.rs => db/mod.rs} | 36 +- service/src/{ => db}/schema.rs | 14 +- service/src/error_handler.rs | 116 ++-- service/src/main.rs | 63 +- service/src/metars/model.rs | 4 +- service/src/users/mod.rs | 4 - service/src/users/model.rs | 50 -- service/src/users/routes.rs | 31 +- service/src/users/user_type.rs | 35 - 25 files changed, 1268 insertions(+), 450 deletions(-) rename service/migrations/{000000_create_airports => 000000_airports}/down.sql (100%) rename service/migrations/{000000_create_airports => 000000_airports}/up.sql (100%) rename service/migrations/{000001_create_metars => 000001_metars}/down.sql (100%) rename service/migrations/{000001_create_metars => 000001_metars}/up.sql (100%) delete mode 100644 service/migrations/000002_create_users/up.sql rename service/migrations/{000002_create_users => 000002_users}/down.sql (100%) create mode 100644 service/migrations/000002_users/up.sql create mode 100644 service/src/auth/model.rs create mode 100644 service/src/auth/routes.rs rename service/src/{db.rs => db/mod.rs} (72%) rename service/src/{ => db}/schema.rs (87%) delete mode 100644 service/src/users/model.rs delete mode 100644 service/src/users/user_type.rs diff --git a/service/.env.TEMPLATE b/service/.env.TEMPLATE index aaa1c40..bd32f34 100644 --- a/service/.env.TEMPLATE +++ b/service/.env.TEMPLATE @@ -1,11 +1,21 @@ -RUST_LOG=debug,actix=warn,diesel_migrations=warn,reqwest=warn,hyper=warn,tracing=warn,mio=warn +RUST_LOG=waren,service=info -DATABASE_CONTAINER=weather-db -DATABASE_HOST=db DATABASE_USER=weather DATABASE_PASSWORD= DATABASE_NAME=weather +DATABASE_HOST=localhost DATABASE_PORT=5432 -SERVICE_HOST=service +REDIS_HOST=localhost +REDIS_PORT=6379 + +SERVICE_HOST=localhost SERVICE_PORT=5000 + +ACCESS_TOKEN_PRIVATE_KEY= +ACCESS_TOKEN_PUBLIC_KEY= +ACCESS_TOKEN_MAXAGE=5 + +REFRESH_TOKEN_PRIVATE_KEY= +REFRESH_TOKEN_PUBLIC_KEY= +REFRESH_TOKEN_MAXAGE=30 diff --git a/service/Cargo.lock b/service/Cargo.lock index 7ca2bba..160b495 100644 --- a/service/Cargo.lock +++ b/service/Cargo.lock @@ -45,7 +45,7 @@ dependencies = [ "actix-service", "actix-utils", "ahash", - "base64 0.21.4", + "base64", "bitflags 2.4.0", "brotli", "bytes", @@ -73,22 +73,6 @@ dependencies = [ "zstd", ] -[[package]] -name = "actix-identity" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36e1cc6f95e245b2f3c6995df4e1c0c697704c48c28ec325d135a3ca039d4952" -dependencies = [ - "actix-service", - "actix-session", - "actix-utils", - "actix-web", - "derive_more", - "futures-core", - "serde", - "tracing", -] - [[package]] name = "actix-macros" version = "0.2.4" @@ -99,6 +83,44 @@ dependencies = [ "syn 2.0.32", ] +[[package]] +name = "actix-multipart" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b960e2aea75f49c8f069108063d12a48d329fc8b60b786dfc7552a9d5918d2d" +dependencies = [ + "actix-multipart-derive", + "actix-utils", + "actix-web", + "bytes", + "derive_more", + "futures-core", + "futures-util", + "httparse", + "local-waker", + "log", + "memchr", + "mime", + "serde", + "serde_json", + "serde_plain", + "tempfile", + "tokio", +] + +[[package]] +name = "actix-multipart-derive" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a0a77f836d869f700e5b47ac7c3c8b9c8bc82e4aec861954c6198abee3ebd4d" +dependencies = [ + "darling", + "parse-size", + "proc-macro2", + "quote", + "syn 2.0.32", +] + [[package]] name = "actix-router" version = "0.5.1" @@ -118,7 +140,6 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28f32d40287d3f402ae0028a9d54bef51af15c8769492826a69d28f81893151d" dependencies = [ - "actix-macros", "futures-core", "tokio", ] @@ -151,23 +172,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "actix-session" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e6a28f813a6671e1847d005cad0be36ae4d016287690f765c303379837c13d6" -dependencies = [ - "actix-service", - "actix-utils", - "actix-web", - "anyhow", - "async-trait", - "derive_more", - "serde", - "serde_json", - "tracing", -] - [[package]] name = "actix-utils" version = "3.0.1" @@ -230,6 +234,21 @@ dependencies = [ "syn 2.0.32", ] +[[package]] +name = "actix-web-httpauth" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d613edf08a42ccc6864c941d30fe14e1b676a77d16f1dbadc1174d065a0a775" +dependencies = [ + "actix-utils", + "actix-web", + "base64", + "futures-core", + "futures-util", + "log", + "pin-project-lite", +] + [[package]] name = "addr2line" version = "0.21.0" @@ -245,41 +264,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "aes-gcm" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "209b47e8954a928e1d72e86eca7000ebb6655fe1436d33eefc2201cad027e237" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - [[package]] name = "ahash" version = "0.8.3" @@ -332,16 +316,28 @@ dependencies = [ ] [[package]] -name = "anyhow" -version = "1.0.75" +name = "arc-swap" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" +checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6" + +[[package]] +name = "argon2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ba4cac0a46bc1d2912652a751c47f2a9f3a7fe89bcae2275d418f5270402f9" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] [[package]] name = "async-trait" -version = "0.1.73" +version = "0.1.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc00ceb34980c03614e35a3a4e218276a0a824e911d07651cd0d858a51e8c0f0" +checksum = "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9" dependencies = [ "proc-macro2", "quote", @@ -369,18 +365,18 @@ dependencies = [ "rustc-demangle", ] -[[package]] -name = "base64" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" - [[package]] name = "base64" version = "0.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ba43ea6f343b788c8764558649e08df62f86c6ef251fdaeb1ffd010a9ae50a2" +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "bitflags" version = "1.3.2" @@ -393,6 +389,15 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -482,13 +487,17 @@ dependencies = [ ] [[package]] -name = "cipher" -version = "0.4.4" +name = "combine" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4" dependencies = [ - "crypto-common", - "inout", + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", ] [[package]] @@ -503,14 +512,7 @@ version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" dependencies = [ - "aes-gcm", - "base64 0.20.0", - "hkdf", - "hmac", "percent-encoding", - "rand", - "sha2", - "subtle", "time", "version_check", ] @@ -556,17 +558,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core", "typenum", ] [[package]] -name = "ctr" -version = "0.9.2" +name = "darling" +version = "0.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +checksum = "0209d94da627ab5605dcccf08bb18afa5009cfbef48d8a8b7d7bdbc79be25c5e" dependencies = [ - "cipher", + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "177e3443818124b357d8e76f53be906d60937f0d3a90773a664fa63fa253e621" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.32", +] + +[[package]] +name = "darling_macro" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.32", ] [[package]] @@ -748,6 +775,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.28" @@ -755,6 +797,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -763,6 +806,34 @@ version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" +[[package]] +name = "futures-executor" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf34a163b5c4c52d0478a4d757da8fb65cabef42ba90515efee0f6f9fa45aaa" + +[[package]] +name = "futures-macro" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + [[package]] name = "futures-sink" version = "0.3.28" @@ -781,10 +852,16 @@ version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -808,16 +885,6 @@ dependencies = [ "wasi", ] -[[package]] -name = "ghash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d930750de5717d2dd0b8c0d42c076c0e884c81a73e6cab859bbd2339c71e3e40" -dependencies = [ - "opaque-debug", - "polyval", -] - [[package]] name = "gimli" version = "0.28.0" @@ -861,24 +928,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" -[[package]] -name = "hkdf" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "http" version = "0.2.9" @@ -979,6 +1028,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.4.0" @@ -1009,15 +1064,6 @@ dependencies = [ "hashbrown 0.14.0", ] -[[package]] -name = "inout" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" -dependencies = [ - "generic-array", -] - [[package]] name = "ipnet" version = "2.8.0" @@ -1059,6 +1105,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "155c4d7e39ad04c172c5e3a99c434ea3b4a7ba7960b38ecd562b270b097cce09" +dependencies = [ + "base64", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "language-tags" version = "0.3.2" @@ -1073,9 +1133,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.147" +version = "0.2.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" +checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" [[package]] name = "linux-raw-sys" @@ -1083,17 +1143,6 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a9bad9f94746442c783ca431b22403b519cd7fbeed0533fdd6328b2f2212128" -[[package]] -name = "listenfd" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0500463acd96259d219abb05dc57e5a076ef04b2db9a2112846929b5f174c96" -dependencies = [ - "libc", - "uuid", - "winapi", -] - [[package]] name = "local-channel" version = "0.1.3" @@ -1200,6 +1249,27 @@ dependencies = [ "tempfile", ] +[[package]] +name = "num-bigint" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.16" @@ -1224,12 +1294,6 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" -[[package]] -name = "opaque-debug" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" - [[package]] name = "openssl" version = "0.10.57" @@ -1297,18 +1361,65 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "parse-size" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "944553dd59c802559559161f9816429058b869003836120e262e8caec061b7ae" + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "paste" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[package]] +name = "pem" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3163d2912b7c3b52d651a055f2c7eec9ba5cd22d26ef75b8dd3a59980b185923" +dependencies = [ + "base64", + "serde", +] + [[package]] name = "percent-encoding" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" +[[package]] +name = "pin-project" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda4ed1c6c173e3fc7a83629421152e01d7b1f9b7f65fb301e490e8cfc656422" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + [[package]] name = "pin-project-lite" version = "0.2.13" @@ -1327,18 +1438,6 @@ version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" -[[package]] -name = "polyval" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52cff9d1d4dee5fe6d03729099f4a310a41179e0a10dbf542039873f2e826fb" -dependencies = [ - "cfg-if", - "cpufeatures", - "opaque-debug", - "universal-hash", -] - [[package]] name = "postgis_diesel" version = "2.2.1" @@ -1434,6 +1533,31 @@ dependencies = [ "getrandom", ] +[[package]] +name = "redis" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f49cdc0bb3f412bf8e7d1bd90fe1d9eb10bc5c399ba90973c14662a27b3f8ba" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "combine", + "futures", + "futures-util", + "itoa", + "percent-encoding", + "pin-project-lite", + "r2d2", + "ryu", + "sha1_smol", + "socket2 0.4.9", + "tokio", + "tokio-retry", + "tokio-util", + "url", +] + [[package]] name = "redox_syscall" version = "0.3.5" @@ -1478,7 +1602,7 @@ version = "0.11.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78fdbab6a7e1d7b13cc8ff10197f47986b41c639300cc3c8158cac7847c9bbef" dependencies = [ - "base64 0.21.4", + "base64", "bytes", "encoding_rs", "futures-core", @@ -1510,6 +1634,20 @@ dependencies = [ "winreg", ] +[[package]] +name = "ring" +version = "0.17.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb0205304757e5d899b9c2e448b867ffd03ae7f988002e47cd24954391394d0b" +dependencies = [ + "cc", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -1628,6 +1766,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.3" @@ -1649,6 +1796,35 @@ dependencies = [ "serde", ] +[[package]] +name = "service" +version = "0.1.0" +dependencies = [ + "actix-cors", + "actix-multipart", + "actix-web", + "actix-web-httpauth", + "argon2", + "base64", + "chrono", + "diesel", + "diesel_migrations", + "dotenv", + "env_logger", + "jsonwebtoken", + "lazy_static", + "log", + "postgis_diesel", + "quick-xml", + "r2d2", + "redis", + "reqwest", + "serde", + "serde_json", + "tokio", + "uuid", +] + [[package]] name = "sha1" version = "0.10.5" @@ -1661,15 +1837,10 @@ dependencies = [ ] [[package]] -name = "sha2" -version = "0.10.7" +name = "sha1_smol" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479fb9d862239e610720565ca91403019f2f00410f1864c5aa7479b950a76ed8" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] +checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" [[package]] name = "signal-hook-registry" @@ -1680,6 +1851,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simple_asn1" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.9" @@ -1715,6 +1898,18 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "subtle" version = "2.5.0" @@ -1786,6 +1981,26 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thiserror" +version = "1.0.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + [[package]] name = "time" version = "0.3.28" @@ -1868,6 +2083,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-retry" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f57eb36ecbe0fc510036adff84824dd3c24bb781e21bfa67b69d556aa85214f" +dependencies = [ + "pin-project", + "rand", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.8" @@ -1977,14 +2203,10 @@ dependencies = [ ] [[package]] -name = "universal-hash" -version = "0.5.1" +name = "untrusted" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" @@ -2100,32 +2322,6 @@ version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" -[[package]] -name = "weather-service" -version = "0.1.0" -dependencies = [ - "actix-cors", - "actix-identity", - "actix-rt", - "actix-web", - "chrono", - "diesel", - "diesel_migrations", - "dotenv", - "env_logger", - "lazy_static", - "listenfd", - "log", - "postgis_diesel", - "quick-xml", - "r2d2", - "reqwest", - "serde", - "serde_json", - "tokio", - "uuid", -] - [[package]] name = "web-sys" version = "0.3.64" diff --git a/service/Cargo.toml b/service/Cargo.toml index 41b3b61..aea98d0 100644 --- a/service/Cargo.toml +++ b/service/Cargo.toml @@ -1,15 +1,19 @@ [package] -name = "weather-service" +name = "service" version = "0.1.0" edition = "2021" +authors = ["Ben Sherriff "] +repository = "https://github.com/bensherriff/aviation-weather" +readme = "README.md" +license = "GPL-3.0-or-later" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] actix-web = "4.4.0" -actix-rt = "2.9.0" actix-cors = "0.6.4" -actix-identity = "0.6.0" +actix-web-httpauth = "0.8.1" +actix-multipart = "0.6.1" chrono = { version = "0.4.31", features = ["serde"] } dotenv = "0.15.0" diesel = { version = "2.1.2", features = ["postgres", "r2d2", "uuid", "chrono"] } @@ -17,7 +21,6 @@ postgis_diesel = { version = "2.2.1", features = ["serde"] } diesel_migrations = { version = "2.1.0", features = ["postgres"] } env_logger = "0.10.0" lazy_static = "1.4.0" -listenfd = "1.0.1" quick-xml = { version = "0.30.0", features = ["serialize"] } r2d2 = "0.8.10" reqwest = "0.11.21" @@ -25,4 +28,8 @@ serde = {version = "1.0.188", features = ["derive"]} serde_json = "1.0.107" tokio = { version = "1.32.0", features = ["macros", "rt", "time"] } uuid = { version = "1.4.1", features = ["serde", "v4"] } -log = "0.4.20" \ No newline at end of file +log = "0.4.20" +argon2 = "0.5.2" +jsonwebtoken = "9.0.0" +redis = { version = "0.23.3", features = ["tokio-comp", "connection-manager", "r2d2"] } +base64 = "0.21.4" diff --git a/service/Makefile b/service/Makefile index fb4b5d5..46caff1 100644 --- a/service/Makefile +++ b/service/Makefile @@ -14,6 +14,10 @@ help: ## This info build: ## Build Docker containers docker compose build +utils: ## Start the utils + docker compose up -d db + docker compose up -d redis + up: ## Start Docker containers docker compose up -d diff --git a/service/docker-compose.yml b/service/docker-compose.yml index 02c26bf..f766e19 100644 --- a/service/docker-compose.yml +++ b/service/docker-compose.yml @@ -17,7 +17,15 @@ services: ports: - "${DATABASE_PORT:-5432}:5432" networks: - - weather-backend + - backend + restart: unless-stopped + redis: + image: redis:latest + container_name: weather-redis + ports: + - ${REDIS_PORT:-6379}:6379 + networks: + - backend restart: unless-stopped service: @@ -27,6 +35,8 @@ services: environment: DATABASE_HOST: db DATABASE_PORT: 5432 + REDIS_HOST: redis + REDIS_PORT: 6379 SERVICE_HOST: service SERVICE_PORT: 5000 ports: @@ -35,9 +45,10 @@ services: context: . depends_on: - db + - redis networks: - - weather-frontend - - weather-backend + - frontend + - backend restart: unless-stopped volumes: @@ -45,5 +56,5 @@ volumes: db_logs: networks: - weather-frontend: {} - weather-backend: {} + frontend: + backend: diff --git a/service/migrations/000000_create_airports/down.sql b/service/migrations/000000_airports/down.sql similarity index 100% rename from service/migrations/000000_create_airports/down.sql rename to service/migrations/000000_airports/down.sql diff --git a/service/migrations/000000_create_airports/up.sql b/service/migrations/000000_airports/up.sql similarity index 100% rename from service/migrations/000000_create_airports/up.sql rename to service/migrations/000000_airports/up.sql diff --git a/service/migrations/000001_create_metars/down.sql b/service/migrations/000001_metars/down.sql similarity index 100% rename from service/migrations/000001_create_metars/down.sql rename to service/migrations/000001_metars/down.sql diff --git a/service/migrations/000001_create_metars/up.sql b/service/migrations/000001_metars/up.sql similarity index 100% rename from service/migrations/000001_create_metars/up.sql rename to service/migrations/000001_metars/up.sql diff --git a/service/migrations/000002_create_users/up.sql b/service/migrations/000002_create_users/up.sql deleted file mode 100644 index b7f0206..0000000 --- a/service/migrations/000002_create_users/up.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE TABLE IF NOT EXISTS users ( - id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, - email TEXT NOT NULL, - username TEXT NOT NULL, - password TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - first_name TEXT NOT NULL, - last_name TEXT NOT NULL, - favorites TEXT[] -); \ No newline at end of file diff --git a/service/migrations/000002_create_users/down.sql b/service/migrations/000002_users/down.sql similarity index 100% rename from service/migrations/000002_create_users/down.sql rename to service/migrations/000002_users/down.sql diff --git a/service/migrations/000002_users/up.sql b/service/migrations/000002_users/up.sql new file mode 100644 index 0000000..97e07e6 --- /dev/null +++ b/service/migrations/000002_users/up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS users ( + email TEXT PRIMARY KEY NOT NULL, + hash TEXT NOT NULL, + role TEXT NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + profile_picture TEXT, + verified BOOLEAN NOT NULL DEFAULT FALSE +); \ No newline at end of file diff --git a/service/src/airports/model.rs b/service/src/airports/model.rs index 5a42216..83d7544 100644 --- a/service/src/airports/model.rs +++ b/service/src/airports/model.rs @@ -1,6 +1,6 @@ use crate::db; use crate::error_handler::ServiceError; -use crate::schema::airports; +use crate::db::schema::airports; use diesel::dsl::count_star; use diesel::prelude::*; // use log::trace; diff --git a/service/src/auth/mod.rs b/service/src/auth/mod.rs index e69de29..ee6f8fe 100644 --- a/service/src/auth/mod.rs +++ b/service/src/auth/mod.rs @@ -0,0 +1,97 @@ +use std::env; + +use argon2::{password_hash::{rand_core::OsRng, PasswordHasher, PasswordVerifier, SaltString, Error as HashError}, Argon2, PasswordHash}; +use base64::{engine::general_purpose, Engine as _}; +use jsonwebtoken::{DecodingKey, EncodingKey, Header, encode, decode, Validation, Algorithm}; +use serde::{Deserialize, Serialize}; + +mod model; +mod routes; + +pub use model::*; +pub use routes::init_routes; +use crate::error_handler::ServiceError; + +#[derive(Debug, Serialize, Deserialize)] +struct TokenClaims { + sub: String, // Subject + token_uuid: String, // Token UUID + iss: String, // Issuer + exp: i64, // Expiration time + iat: i64, // Issued At + nbf: i64 // Not Before +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenDetails { + pub token: Option, + pub token_uuid: uuid::Uuid, + pub email: String, + pub expires_in: Option +} + +pub fn verify_token(token: &str, public_key: &str) -> Result { + let bytes_public_key = general_purpose::STANDARD.decode(public_key).unwrap(); + let decoded_public_key = String::from_utf8(bytes_public_key).unwrap(); + let key = DecodingKey::from_rsa_pem(decoded_public_key.as_bytes())?; + let validation = Validation::new(Algorithm::RS256); + let decoded = decode::(token, &key, &validation)?; + let email = decoded.claims.sub; + let token_uuid = uuid::Uuid::parse_str(decoded.claims.token_uuid.as_str()).unwrap(); + Ok(TokenDetails { token: None, token_uuid, email, expires_in: None }) +} + +pub fn generate_access_token(email: &str) -> Result { + let access_token_max_age = env::var("ACCESS_TOKEN_MAXAGE") + .expect("ACCESS_TOKEN_MAXAGE must be set") + .parse::() + .expect("ACCESS_TOKEN_MAXAGE must be an integer"); + let access_private_key = env::var("ACCESS_TOKEN_PRIVATE_KEY") + .expect("ACCESS_TOKEN_PRIVATE_KEY must be set"); + generate_token(&email, access_token_max_age, &access_private_key) +} + +pub fn generate_refresh_token(email: &str) -> Result { + let refresh_token_max_age = env::var("REFRESH_TOKEN_MAXAGE") + .expect("REFRESH_TOKEN_MAXAGE must be set") + .parse::() + .expect("REFRESH_TOKEN_MAXAGE must be an integer"); + let refresh_private_key = env::var("REFRESH_TOKEN_PRIVATE_KEY") + .expect("REFRESH_TOKEN_PRIVATE_KEY must be set"); + generate_token(&email, refresh_token_max_age, &refresh_private_key) +} + +pub fn generate_token(email: &str, ttl: i64, private_key: &str) -> Result { + let now = chrono::Utc::now(); + let mut token_details = TokenDetails { + token: None, + token_uuid: uuid::Uuid::new_v4(), + email: email.to_string(), + expires_in: Some((now + chrono::Duration::minutes(ttl)).timestamp()) + }; + let claims = TokenClaims { + sub: token_details.email.clone(), + iss: "aviation-weather".to_string(), + token_uuid: token_details.token_uuid.to_string(), + exp: token_details.expires_in.unwrap(), + iat: now.timestamp(), + nbf: now.timestamp() + }; + let header = Header::new(Algorithm::RS256); + let bytes_private_key = general_purpose::STANDARD.decode(private_key).unwrap(); + let decoded_private_key = String::from_utf8(bytes_private_key).unwrap(); + let key = EncodingKey::from_rsa_pem(decoded_private_key.as_bytes())?; + let token = encode(&header, &claims, &key)?; + token_details.token = Some(token); + Ok(token_details) +} + +pub fn hash_password(password: &[u8]) -> Result { + let salt = SaltString::generate(&mut OsRng); + Ok(Argon2::default().hash_password(password, &salt)?.to_string()) +} + +pub fn verify_password(hash: &str, password: &[u8]) -> Result<(), HashError> { + let parsed_hash = PasswordHash::new(hash)?; + Ok(Argon2::default().verify_password(password, &parsed_hash)?) +} \ No newline at end of file diff --git a/service/src/auth/model.rs b/service/src/auth/model.rs new file mode 100644 index 0000000..f100c02 --- /dev/null +++ b/service/src/auth/model.rs @@ -0,0 +1,205 @@ +use std::{future::{ready, Ready}, env}; +use actix_web::{FromRequest, Error as ActixError, HttpRequest, dev::Payload, http}; +use diesel::prelude::*; +use log::error; +use redis::Commands; +use serde::{Serialize, Deserialize}; +use crate::error_handler::ServiceError; + +use crate::db::{schema::users, connection}; + +use super::{hash_password, verify_token}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct RegisterUser { + pub email: String, + pub password: String, + pub first_name: String, + pub last_name: String, +} + +impl RegisterUser { + pub fn convert_to_insert(self) -> Result { + let hash = hash_password(self.password.as_bytes())?; + Ok(InsertUser { + email: self.email.to_lowercase(), + hash, + role: "user".to_string(), + first_name: self.first_name, + last_name: self.last_name, + updated_at: chrono::Utc::now().naive_utc(), + created_at: chrono::Utc::now().naive_utc(), + profile_picture: None, + verified: false, + }) + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} + +#[derive(Debug, Queryable, QueryableByName, Serialize, Deserialize)] +#[diesel(table_name = users)] +pub struct QueryUser { + pub email: String, + pub hash: String, + pub role: String, + pub first_name: String, + pub last_name: String, + pub updated_at: chrono::NaiveDateTime, + pub created_at: chrono::NaiveDateTime, + pub profile_picture: Option, + pub verified: bool, +} + +impl QueryUser { + pub fn get_by_email(email: &str) -> Result { + let mut conn = connection()?; + // Check if the user exists by email, case insensitive + + let user = users::table + .filter(users::email.eq(email.to_lowercase())) + .first(&mut conn)?; + Ok(user) + } +} + +#[derive(Debug, Insertable, AsChangeset, Serialize, Deserialize)] +#[diesel(table_name = users)] +pub struct InsertUser { + pub email: String, + pub hash: String, + pub role: String, + pub first_name: String, + pub last_name: String, + pub updated_at: chrono::NaiveDateTime, + pub created_at: chrono::NaiveDateTime, + pub profile_picture: Option, + pub verified: bool, +} + +impl InsertUser { + pub fn insert(user: Self) -> Result { + let mut conn = connection()?; + let user = diesel::insert_into(users::table) + .values(user) + .get_result(&mut conn)?; + Ok(user) + } + + pub fn update_profile(email: &str, profile_picture: Option<&str>) -> Result { + let mut conn = connection()?; + let user = diesel::update(users::table) + .filter(users::email.eq(&email)) + .set(users::profile_picture.eq(profile_picture)) + .get_result(&mut conn)?; + Ok(user) + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ResponseUser { + pub email: String, + pub role: String, + pub first_name: String, + pub last_name: String, + pub profile_picture: Option, +} + +impl From for ResponseUser { + fn from(user: QueryUser) -> Self { + ResponseUser { + email: user.email, + role: user.role, + first_name: user.first_name, + last_name: user.last_name, + profile_picture: user.profile_picture, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct JwtAuth { + pub token: uuid::Uuid, + pub user: ResponseUser +} + +impl FromRequest for JwtAuth { + type Error = ActixError; + type Future = Ready>; + fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { + let access_token = match req + .cookie("access_token") + .map(|c| c.value().to_string()) + .or_else(|| { + req.headers().get(http::header::AUTHORIZATION) + .map(|h| h.to_str().unwrap().split_at(7).1.to_string()) + }) { + Some(token) => token, + None => return ready(Err(ActixError::from(ServiceError { + status: 401, + message: "Unauthorized".to_string() + }))) + }; + + let public_key = env::var("ACCESS_TOKEN_PUBLIC_KEY") + .expect("ACCESS_TOKEN_PUBLIC_KEY must be set"); + + let access_token_details = match verify_token(&access_token, &public_key) { + Ok(token_details) => token_details, + Err(err) => { + error!("Failed to verify access token: {}", err); + return ready(Err(ActixError::from(ServiceError { + status: 401, + message: format!("Failed to verify access token: {}", err) + }))) + } + }; + + let access_token_uuid = uuid::Uuid::parse_str(&access_token_details.token_uuid.to_string()).unwrap(); + + let mut conn = match crate::db::redis_connection() { + Ok(conn) => conn, + Err(err) => { + error!("Failed to get redis connection: {}", err); + return ready(Err(ActixError::from(ServiceError { + status: 500, + message: format!("Failed to get redis connection: {}", err) + }))) + } + }; + let user_email = match conn.get::<_, String>(access_token_uuid.clone().to_string()) { + Ok(result) => result, + Err(_) => { + return ready(Err(ActixError::from(ServiceError { + status: 401, + message: format!("Access token was not found") + }))) + } + }; + + match QueryUser::get_by_email(&user_email) { + Ok(user) => { + ready(Ok(JwtAuth { token: access_token_uuid, user: user.into() })) + } + Err(_) => return ready(Err(ActixError::from(ServiceError { + status: 401, + message: format!("User was not found") + }))) + } + } +} + +pub fn verify_role(auth: &JwtAuth, role: &str) -> Result<(), ServiceError> { + if auth.user.role == role { + Ok(()) + } else { + Err(ServiceError { + status: 403, + message: "Forbidden".to_string() + }) + } +} diff --git a/service/src/auth/routes.rs b/service/src/auth/routes.rs new file mode 100644 index 0000000..5172a23 --- /dev/null +++ b/service/src/auth/routes.rs @@ -0,0 +1,367 @@ +use std::env; + +use actix_web::{get, post, web, HttpResponse, ResponseError, cookie::{Cookie, time::Duration}, HttpRequest}; +use log::error; +use redis::AsyncCommands; +use serde::{Serialize, Deserialize}; +use crate::error_handler::ServiceError; + +use crate::{auth::{LoginRequest, RegisterUser, InsertUser, QueryUser, verify_password, JwtAuth, verify_token, generate_access_token, generate_refresh_token}, db}; + +#[post("/register")] +async fn register(user: web::Json) -> HttpResponse { + let register_user = user.0; + let insert_user: InsertUser = match register_user.convert_to_insert() { + Ok(user) => user, + Err(err) => return ResponseError::error_response(&err) + }; + match InsertUser::insert(insert_user) { + Ok(_) => { + HttpResponse::Created().finish() + }, + Err(err) => { + // Obfuscate the service error message to prevent leaking database details + if err.status == 409 { + return HttpResponse::Conflict().finish(); + } else { + return ResponseError::error_response(&err); + } + } + } +} + +#[post("/login")] +async fn login(request: web::Json) -> HttpResponse { + let email = request.email.clone(); + + let query_user = match QueryUser::get_by_email(&email) { + Ok(query_user) => query_user, + Err(err) => return ResponseError::error_response(&err) + }; + let hash = &query_user.hash; + let password = request.password.as_bytes(); + match verify_password(hash, password) { + Ok(_) => { + let access_token_details = match generate_access_token(&email) { + Ok(token_details) => token_details, + Err(err) => { + error!("Failed to generate access token: {}", err); + return ResponseError::error_response(&err) + } + }; + + let refresh_token_details = match generate_refresh_token(&email) { + Ok(token_details) => token_details, + Err(err) => { + error!("Failed to generate refresh token: {}", err); + return ResponseError::error_response(&err) + } + }; + + let mut conn = match db::redis_async_connection().await { + Ok(conn) => conn, + Err(err) => { + error!("Failed to get redis connection: {}", err); + return ResponseError::error_response(&err) + } + }; + + let access_token_max_age = env::var("ACCESS_TOKEN_MAXAGE") + .expect("ACCESS_TOKEN_MAXAGE must be set") + .parse::() + .expect("ACCESS_TOKEN_MAXAGE must be an integer"); + + let refresh_token_max_age = env::var("REFRESH_TOKEN_MAXAGE") + .expect("REFRESH_TOKEN_MAXAGE must be set") + .parse::() + .expect("REFRESH_TOKEN_MAXAGE must be an integer"); + + let access_result: redis::RedisResult<()> = conn.set_ex(access_token_details.token_uuid.to_string(), &email, (access_token_max_age * 60) as usize).await; + if let Err(err) = access_result { + error!("Failed to set access token in redis: {}", err); + return ResponseError::error_response(&ServiceError { + status: 500, + message: format!("Failed to set access token in redis: {}", err) + }) + }; + + let refresh_result: redis::RedisResult<()> = conn.set_ex(refresh_token_details.token_uuid.to_string(), &email, (refresh_token_max_age * 60) as usize).await; + if let Err(err) = refresh_result { + error!("Failed to set refresh token in redis: {}", err); + return ResponseError::error_response(&ServiceError { + status: 500, + message: format!("Failed to set refresh token in redis: {}", err) + }) + }; + + let access_cookie = Cookie::build("access_token", access_token_details.token.clone().unwrap()) + .path("/") + .max_age(Duration::new(access_token_max_age * 60, 0)) + .http_only(true) + .secure(true) + .finish(); + let refresh_cookie = Cookie::build("refresh_token", refresh_token_details.token.clone().unwrap()) + .path("/") + .max_age(Duration::new(refresh_token_max_age * 60, 0)) + .http_only(true) + .secure(true) + .finish(); + let logged_in_cookie = Cookie::build("logged_in", "true") + .path("/") + .max_age(Duration::new(access_token_max_age * 60, 0)) + .http_only(false) + .finish(); + + let access_token_uuid = uuid::Uuid::parse_str(&access_token_details.token_uuid.to_string()).unwrap(); + + HttpResponse::Ok() + .cookie(access_cookie) + .cookie(refresh_cookie) + .cookie(logged_in_cookie) + .json(JwtAuth { token: access_token_uuid, user: query_user.into() }) + }, + Err(err) => ResponseError::error_response(&ServiceError { + status: 401, + message: err.to_string() + }) + } +} + +#[derive(Serialize, Deserialize)] +struct RefreshParams { + refresh_token_rotation: Option +} + +#[get("/refresh")] +async fn refresh(req: HttpRequest) -> HttpResponse { + let params = match web::Query::::from_query(req.query_string()) { + Ok(params) => params, + Err(err) => return ResponseError::error_response(&ServiceError { + status: 422, + message: err.to_string() + }) + }; + + let refresh_token = match req.cookie("refresh_token") { + Some(cookie) => cookie.value().to_string(), + None => return ResponseError::error_response(&ServiceError { + status: 401, + message: "Refresh token not found".to_string() + }) + }; + + let public_key = env::var("REFRESH_TOKEN_PUBLIC_KEY") + .expect("REFRESH_TOKEN_PUBLIC_KEY must be set"); + let refresh_token_details = match verify_token(&refresh_token, &public_key) { + Ok(token_details) => token_details, + Err(err) => return ResponseError::error_response(&err) + }; + + let email = refresh_token_details.email.clone(); + + match QueryUser::get_by_email(&email) { + Ok(query_user) => { + let access_token_details = match generate_access_token(&email) { + Ok(token_details) => token_details, + Err(err) => { + error!("Failed to generate access token: {}", err); + return ResponseError::error_response(&err) + } + }; + + let mut conn = match db::redis_async_connection().await { + Ok(conn) => conn, + Err(err) => { + error!("Failed to get redis connection: {}", err); + return ResponseError::error_response(&err) + } + }; + + // Delete old auth token if it exists + match req.cookie("access_token") { + Some(cookie) => { + let access_token = cookie.value().to_string(); + let public_key = env::var("ACCESS_TOKEN_PUBLIC_KEY") + .expect("ACCESS_TOKEN_PUBLIC_KEY must be set"); + match verify_token(&access_token, &public_key) { + Ok(token_details) => { + let _: redis::RedisResult<()> = conn.del(token_details.token_uuid.to_string()).await; + + }, + Err(_) => {} + }; + }, + None => {} + }; + + let access_token_max_age = env::var("ACCESS_TOKEN_MAXAGE") + .expect("ACCESS_TOKEN_MAXAGE must be set") + .parse::() + .expect("ACCESS_TOKEN_MAXAGE must be an integer"); + + let access_result: redis::RedisResult<()> = conn.set_ex(access_token_details.token_uuid.to_string(), &email, (access_token_max_age * 60) as usize).await; + if let Err(err) = access_result { + error!("Failed to set access token in redis: {}", err); + return ResponseError::error_response(&ServiceError { + status: 500, + message: format!("Failed to set access token in redis: {}", err) + }) + }; + + let access_cookie = Cookie::build("access_token", access_token_details.token.clone().unwrap()) + .path("/") + .max_age(Duration::new(access_token_max_age * 60, 0)) + .http_only(true) + .secure(true) + .finish(); + let logged_in_cookie = Cookie::build("logged_in", "true") + .path("/") + .max_age(Duration::new(access_token_max_age * 60, 0)) + .http_only(false) + .finish(); + + let access_token_uuid = uuid::Uuid::parse_str(&access_token_details.token_uuid.to_string()).unwrap(); + + // Refresh the refresh token if requested + let refresh_token_rotation = match params.refresh_token_rotation { + Some(refresh_token_rotation) => refresh_token_rotation, + None => false + }; + if refresh_token_rotation { + // Delete the old refresh token + let _: redis::RedisResult<()> = conn.del(refresh_token_details.token_uuid.to_string()).await; + + let refresh_token_details = match generate_refresh_token(&refresh_token_details.email) { + Ok(token_details) => token_details, + Err(err) => { + error!("Failed to generate refresh token: {}", err); + return ResponseError::error_response(&err) + } + }; + + let refresh_token_max_age = env::var("REFRESH_TOKEN_MAXAGE") + .expect("REFRESH_TOKEN_MAXAGE must be set") + .parse::() + .expect("REFRESH_TOKEN_MAXAGE must be an integer"); + + let refresh_result: redis::RedisResult<()> = conn.set_ex(refresh_token_details.token_uuid.to_string(), &refresh_token_details.email, (refresh_token_max_age * 60) as usize).await; + if let Err(err) = refresh_result { + error!("Failed to set refresh token in redis: {}", err); + return ResponseError::error_response(&ServiceError { + status: 500, + message: format!("Failed to set refresh token in redis: {}", err) + }) + }; + + let refresh_cookie = Cookie::build("refresh_token", refresh_token_details.token.clone().unwrap()) + .path("/") + .max_age(Duration::new(refresh_token_max_age * 60, 0)) + .http_only(true) + .secure(true) + .finish(); + + HttpResponse::Ok() + .cookie(refresh_cookie) + .cookie(access_cookie) + .cookie(logged_in_cookie) + .json(JwtAuth { token: access_token_uuid, user: query_user.into() }) + } else { + HttpResponse::Ok() + .cookie(access_cookie) + .cookie(logged_in_cookie) + .json(JwtAuth { token: access_token_uuid, user: query_user.into() }) + } + }, + Err(err) => return ResponseError::error_response(&err) + } +} + +#[post("/logout")] +async fn logout(req: HttpRequest, auth: JwtAuth) -> HttpResponse { + let refresh_token = match req.cookie("refresh_token") { + Some(cookie) => cookie.value().to_string(), + None => return ResponseError::error_response(&ServiceError { + status: 401, + message: "Refresh token not found".to_string() + }) + }; + let public_key = env::var("REFRESH_TOKEN_PUBLIC_KEY") + .expect("REFRESH_TOKEN_PUBLIC_KEY must be set"); + let refresh_token_details = match verify_token(&refresh_token, &public_key) { + Ok(token_details) => token_details, + Err(err) => return ResponseError::error_response(&err) + }; + + let mut conn = match db::redis_async_connection().await { + Ok(conn) => conn, + Err(err) => { + error!("Failed to get redis connection: {}", err); + return ResponseError::error_response(&err) + } + }; + + let access_result: redis::RedisResult<()> = conn.del(&[ + refresh_token_details.token_uuid.to_string(), + auth.token.to_string() + ]).await; + if let Err(err) = access_result { + error!("Failed to set access token in redis: {}", err); + return ResponseError::error_response(&ServiceError { + status: 500, + message: format!("Failed to set access token in redis: {}", err) + }) + }; + + let access_cookie = Cookie::build("access_token", "") + .path("/") + .max_age(Duration::new(-1, 0)) + .http_only(true) + .finish(); + let refresh_cookie = Cookie::build("refresh_token", "") + .path("/") + .max_age(Duration::new(-1, 0)) + .http_only(true) + .finish(); + let logged_in_cookie = Cookie::build("logged_in", "") + .path("/") + .max_age(Duration::new(-1, 0)) + .http_only(true) + .finish(); + + HttpResponse::Ok() + .cookie(access_cookie) + .cookie(refresh_cookie) + .cookie(logged_in_cookie) + .finish() +} + +#[get("/me")] +async fn me(auth: JwtAuth) -> HttpResponse { + HttpResponse::Ok().json(auth) +} + +#[get("/roles")] +async fn roles() -> HttpResponse { + HttpResponse::Ok().json(vec!["admin", "user"]) +} + +pub fn init_routes(config: &mut web::ServiceConfig) { + let r = RegisterUser { + email: "admin".to_string(), + password: "admin".to_string(), + first_name: "Admin".to_string(), + last_name: "Admin".to_string(), + }; + let mut u = r.convert_to_insert().unwrap(); + u.role = "admin".to_string(); + u.verified = true; + let _ = InsertUser::insert(u); + config.service(web::scope("auth") + .service(register) + .service(login) + .service(refresh) + .service(logout) + .service(me) + .service(roles) + ); +} \ No newline at end of file diff --git a/service/src/db.rs b/service/src/db/mod.rs similarity index 72% rename from service/src/db.rs rename to service/src/db/mod.rs index 7ebb960..9dfc833 100644 --- a/service/src/db.rs +++ b/service/src/db/mod.rs @@ -1,5 +1,6 @@ use crate::{error_handler::ServiceError, airports::{InsertAirport, QueryAirport}}; use diesel::{r2d2::ConnectionManager, PgConnection}; +use redis::{Client as RedisClient, aio::Connection as RedisConnection}; use serde::{Deserialize, Serialize}; use crate::diesel_migrations::MigrationHarness; use lazy_static::lazy_static; @@ -7,6 +8,8 @@ use log::{error, debug, info, warn}; use r2d2; use std::env; +pub mod schema; + type Pool = r2d2::Pool>; pub type DbConnection = r2d2::PooledConnection>; @@ -16,29 +19,24 @@ lazy_static! { static ref POOL: Pool = { let username = env::var("DATABASE_USER").expect("Database username is not set"); let password = env::var("DATABASE_PASSWORD").expect("Database password is not set"); - let host = match env::var("DATABASE_HOST") { - Ok(h) => h, - Err(_) => { - warn!("Defaulting to DATABASE_HOST localhost"); - "localhost".to_string() - } - }; + let host = env::var("DATABASE_HOST").unwrap_or("localhost".to_string()); let name = env::var("DATABASE_NAME").expect("Database name is not set"); - let port = match env::var("DATABASE_PORT") { - Ok(p) => p, - Err(_) => { - warn!("Defaulting to DATABASE_PORT 5432"); - "5432".to_string() - } - }; + let port = env::var("DATABASE_PORT").unwrap_or("5432".to_string()); let url = format!("postgres://{}:{}@{}:{}/{}", username, password, host, port, name); let manager = ConnectionManager::::new(url); Pool::builder().test_on_check_out(true).build(manager).expect("Failed to create db pool") }; + static ref REDIS: RedisClient = { + let host = env::var("REDIS_HOST").unwrap_or("localhost".to_string()); + let port = env::var("REDIS_PORT").unwrap_or("6379".to_string()); + let url = format!("redis://{}:{}", host, port); + RedisClient::open(url).expect("Failed to create redis client") + }; } pub fn init() { lazy_static::initialize(&POOL); + lazy_static::initialize(&REDIS); let mut pool: DbConnection = connection().expect("Failed to get db connection"); match pool.run_pending_migrations(MIGRATIONS) { Ok(_) => info!("Database initialized"), @@ -51,6 +49,16 @@ pub fn connection() -> Result { .map_err(|e| ServiceError::new(500, format!("Failed getting db connection: {}", e))) } +pub fn redis_connection() -> Result { + let conn = REDIS.get_connection()?; + Ok(conn) +} + +pub async fn redis_async_connection() -> Result { + let conn = REDIS.get_async_connection().await?; + Ok(conn) +} + pub fn import_data() { let path = "airport-codes.json"; debug!("Importing data from {}", path); diff --git a/service/src/schema.rs b/service/src/db/schema.rs similarity index 87% rename from service/src/schema.rs rename to service/src/db/schema.rs index ab48724..c4c97c5 100644 --- a/service/src/schema.rs +++ b/service/src/db/schema.rs @@ -48,13 +48,15 @@ diesel::table! { } diesel::table! { - use diesel::sql_types::*; - use crate::users::PgUserType; - users (id) { - id -> Uuid, + users (email) { + email -> Text, + hash -> Text, + role -> Text, first_name -> Text, last_name -> Text, - user_type -> PgUserType, - favorites -> Array + updated_at -> Timestamp, + created_at -> Timestamp, + profile_picture -> Nullable, + verified -> Bool, } } diff --git a/service/src/error_handler.rs b/service/src/error_handler.rs index 5aa4a46..53648ed 100644 --- a/service/src/error_handler.rs +++ b/service/src/error_handler.rs @@ -8,60 +8,100 @@ use std::fmt; #[derive(Debug, Deserialize, Serialize)] pub struct ServiceError { - pub error_status_code: u16, - pub error_message: String, + pub status: u16, + pub message: String, } impl ServiceError { - pub fn new(error_status_code: u16, error_message: String) -> ServiceError { - ServiceError { - error_status_code, - error_message, - } + pub fn new(status: u16, message: String) -> ServiceError { + ServiceError { + status, + message, } + } - pub fn to_http_response(&self) -> HttpResponse { - let status_code = match StatusCode::from_u16(self.error_status_code) { - Ok(s) => s, - Err(err) => { - warn!("{}", err); - StatusCode::INTERNAL_SERVER_ERROR - } - }; - HttpResponse::build(status_code).body(self.error_message.to_string()) - } + pub fn to_http_response(&self) -> HttpResponse { + let status = match StatusCode::from_u16(self.status) { + Ok(s) => s, + Err(err) => { + warn!("{}", err); + StatusCode::INTERNAL_SERVER_ERROR + } + }; + HttpResponse::build(status).body(self.message.to_string()) + } } impl fmt::Display for ServiceError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str(self.error_message.as_str()) - } + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(self.message.as_str()) + } } impl From for ServiceError { - fn from(error: DieselError) -> ServiceError { - match error { - DieselError::DatabaseError(_, err) => ServiceError::new(409, err.message().to_string()), - DieselError::NotFound => { - ServiceError::new(404, "The record was not found".to_string()) - } - err => ServiceError::new(500, format!("Unknown Diesel error: {}", err)), + fn from(error: DieselError) -> ServiceError { + match error { + DieselError::DatabaseError(kind, err) => { + match kind { + diesel::result::DatabaseErrorKind::UniqueViolation => { + ServiceError::new(409, err.message().to_string()) + }, + _ => ServiceError::new(500, err.message().to_string()) } + }, + DieselError::NotFound => { + ServiceError::new(404, "The record was not found".to_string()) + }, + DieselError::SerializationError(err) => { + ServiceError::new(422, err.to_string()) + }, + err => ServiceError::new(500, format!("Unknown Diesel error: {}", err)), } + } +} + +impl From for ServiceError { + fn from(error: reqwest::Error) -> ServiceError { + ServiceError::new(500, format!("Unknown reqwest error: {}", error)) + } +} + +impl From for ServiceError { + fn from(error: serde_json::Error) -> ServiceError { + ServiceError::new(500, format!("Unknown serde_json error: {}", error)) + } +} + +impl From for ServiceError { + fn from(error: argon2::password_hash::Error) -> ServiceError { + ServiceError::new(500, format!("Unknown argon2 error: {}", error)) + } +} + +impl From for ServiceError { + fn from(error: jsonwebtoken::errors::Error) -> ServiceError { + ServiceError::new(500, format!("Unknown jsonwebtoken error: {}", error)) + } +} + +impl From for ServiceError { + fn from(error: redis::RedisError) -> ServiceError { + ServiceError::new(500, format!("Unknown redis error: {}", error)) + } } impl ResponseError for ServiceError { - fn error_response(&self) -> HttpResponse { - let status_code = match StatusCode::from_u16(self.error_status_code) { - Ok(status_code) => status_code, - Err(_) => StatusCode::INTERNAL_SERVER_ERROR, - }; + fn error_response(&self) -> HttpResponse { + let status = match StatusCode::from_u16(self.status) { + Ok(status) => status, + Err(_) => StatusCode::INTERNAL_SERVER_ERROR, + }; - let error_message = match status_code.as_u16() < 500 { - true => self.error_message.clone(), - false => "Internal server error".to_string(), - }; + let message = match status.as_u16() < 500 { + true => self.message.clone(), + false => "Internal server error".to_string(), + }; - HttpResponse::build(status_code).json(json!({ "message": error_message })) - } + HttpResponse::build(status).json(json!({ "status": status.as_u16(), "message": message })) + } } \ No newline at end of file diff --git a/service/src/main.rs b/service/src/main.rs index e85a638..0e66d8e 100644 --- a/service/src/main.rs +++ b/service/src/main.rs @@ -1,14 +1,13 @@ -extern crate actix_web; extern crate diesel; #[macro_use] extern crate diesel_migrations; +use std::env; + use actix_cors::Cors; use actix_web::{App, HttpServer, middleware::Logger}; use dotenv::dotenv; -use env_logger::Env; -use listenfd::ListenFd; -use log::{debug, warn}; +use log::{info, error}; mod airports; mod auth; @@ -16,53 +15,43 @@ mod db; mod error_handler; mod metars; mod users; -mod schema; mod scheduler; -#[actix_rt::main] +#[actix_web::main] async fn main() -> std::io::Result<()> { dotenv().ok(); - if std::env::var_os("RUST_LOG").is_none() { - std::env::set_var("RUST_LOG", "info,actix=info,diesel_migrations=warn,reqwest=warn,hyper=warn"); - } - env_logger::init_from_env(Env::default().default_filter_or("info")); + env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,siren=info")); db::init(); scheduler::update_airports(); - let mut listenfd = ListenFd::from_env(); - let mut server = HttpServer::new(|| { + let host = env::var("SERVICE_HOST").unwrap_or("localhost".to_string()); + let port = env::var("SERVICE_PORT").unwrap_or("5000".to_string()); + + let server = match HttpServer::new(move || { let cors = Cors::default() .allow_any_origin() .allow_any_method() - .allow_any_header(); + .allow_any_header() + .supports_credentials() + .max_age(3600); App::new() - .configure(airports::init_routes) - .configure(metars::init_routes) - .configure(users::init_routes) .wrap(cors) .wrap(Logger::default()) - }); - - server = match listenfd.take_tcp_listener(0)? { - Some(listener) => server.listen(listener)?, - None => { - let host = match std::env::var("SERVICE_HOST") { - Ok(h) => h, - Err(_) => { - warn!("Defaulting to SERVICE_HOST localhost"); - "localhost".to_string() - } - }; - let port = match std::env::var("SERVICE_PORT") { - Ok(p) => p, - Err(_) => { - warn!("Defaulting to SERVICE_PORT 5000"); - "5000".to_string() - } - }; - debug!("Binding server to {}:{}", host, port); - server.bind(format!("{}:{}", host, port))? + .configure(airports::init_routes) + .configure(metars::init_routes) + .configure(auth::init_routes) + .configure(users::init_routes) + }) + .bind(format!("{}:{}", host, port)) { + Ok(b) => { + info!("Binding server to {}:{}", host, port); + b + }, + Err(err) => { + error!("Could not bind server: {}", err); + return Err(err); } }; + server.run().await } diff --git a/service/src/metars/model.rs b/service/src/metars/model.rs index e145a5e..5e911ce 100644 --- a/service/src/metars/model.rs +++ b/service/src/metars/model.rs @@ -1,5 +1,5 @@ use crate::{error_handler::ServiceError, db}; -use crate::schema::metars::{self}; +use crate::db::schema::metars::{self}; use diesel::{prelude::*, sql_query}; use log::{warn, trace}; use std::collections::HashSet; @@ -375,7 +375,7 @@ impl QueryMetar { let mut conn = db::connection()?; let db_metars: Vec = match sql_query(format!("SELECT DISTINCT ON (station_id) * FROM metars WHERE station_id IN ({}) ORDER BY station_id, observation_time DESC", station_query.join(","))).load(&mut conn) { Ok(m) => m, - Err(err) => return Err(ServiceError { error_status_code: 500, error_message: format!("{}", err) }) + Err(err) => return Err(ServiceError { status: 500, message: format!("{}", err) }) }; return Ok(db_metars); } diff --git a/service/src/users/mod.rs b/service/src/users/mod.rs index 6570399..1688bdf 100644 --- a/service/src/users/mod.rs +++ b/service/src/users/mod.rs @@ -1,7 +1,3 @@ -mod model; mod routes; -mod user_type; -pub use user_type::PgUserType; -pub use model::*; pub use routes::init_routes; \ No newline at end of file diff --git a/service/src/users/model.rs b/service/src/users/model.rs deleted file mode 100644 index 75ce23c..0000000 --- a/service/src/users/model.rs +++ /dev/null @@ -1,50 +0,0 @@ -use std::{future::Future, pin::Pin, sync::RwLock}; - -use actix_web::{dev::Payload, error::ErrorUnauthorized, web, Error, FromRequest, HttpRequest}; -use actix_identity::Identity; -use diesel::{query_builder::AsChangeset, prelude::Insertable}; -use log::warn; -use crate::schema::users; -use serde::{Serialize, Deserialize}; - -use super::user_type::UserType; - -#[derive(Serialize, Deserialize, AsChangeset, Insertable)] -#[diesel(table_name = users)] -pub struct InsertUser { - first_name: String, - last_name: String, - user_type: UserType, - favorites: Vec -} - -// impl FromRequest for InsertUser { -// type Config = (); -// type Error = Error; -// type Future = Pin>>>; - -// fn from_request(req: &HttpRequest, pl: &mut Payload) -> Self::Future { -// let fut = Identity::from_request(req, pl); -// let sessions: Option<&web::Data>> = req.app_data(); -// if sessions.is_none() { -// warn!("sessions is empty(none)!"); -// return Box::pin(async { Err(ErrorUnauthorized("unauthorized")) }); -// } -// let sessions = sessions.unwrap().clone(); -// Box::pin(async move { -// if let Some(identity) = fut.await?.identity() { -// if let Some(user) = sessions -// .read() -// .unwrap() -// .map -// .get(&identity) -// .map(|x| x.clone()) -// { -// return Ok(user); -// } -// }; - -// Err(ErrorUnauthorized("unauthorized")) -// }) -// } -// } \ No newline at end of file diff --git a/service/src/users/routes.rs b/service/src/users/routes.rs index 60979c5..46342e5 100644 --- a/service/src/users/routes.rs +++ b/service/src/users/routes.rs @@ -1,29 +1,4 @@ -use actix_web::{get, post, delete, put, web, HttpResponse}; - -#[get("users")] -async fn get() -> HttpResponse { - HttpResponse::NotImplemented().finish() -} - -#[get("users/{id}")] -async fn get_all() -> HttpResponse { - HttpResponse::NotImplemented().finish() -} - -#[post("users")] -async fn create() -> HttpResponse { - HttpResponse::NotImplemented().finish() -} - -#[delete("users")] -async fn delete() -> HttpResponse { - HttpResponse::NotImplemented().finish() -} - -#[put("users")] -async fn update() -> HttpResponse { - HttpResponse::NotImplemented().finish() -} +use actix_web::{get, post, delete, web, HttpResponse}; #[get("users/favorites")] async fn get_favorites() -> HttpResponse { @@ -41,10 +16,6 @@ async fn delete_favorite() -> HttpResponse { } pub fn init_routes(config: &mut web::ServiceConfig) { - config.service(get); - config.service(create); - config.service(delete); - config.service(update); config.service(get_favorites); config.service(add_favorite); config.service(delete_favorite); diff --git a/service/src/users/user_type.rs b/service/src/users/user_type.rs deleted file mode 100644 index 82166ec..0000000 --- a/service/src/users/user_type.rs +++ /dev/null @@ -1,35 +0,0 @@ -use std::io::Write; - -use diesel::{sql_types::SqlType, deserialize::{FromSqlRow, FromSql, self}, expression::AsExpression, serialize::{ToSql, Output, self, IsNull}, pg::{Pg, PgValue}}; -use serde::{Serialize, Deserialize}; - -#[derive(SqlType)] -#[diesel(postgres_type(name = "User_Type"))] -pub struct PgUserType; - -#[derive(Serialize, Deserialize, Debug, PartialEq, FromSqlRow, AsExpression, Eq)] -#[diesel(sql_type = PgUserType)] -pub enum UserType { - Admin, - User, -} - -impl ToSql for UserType { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - match *self { - Self::Admin => out.write_all(b"admin")?, - Self::User => out.write_all(b"user")?, - } - Ok(IsNull::No) - } -} - -impl FromSql for UserType { - fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { - match bytes.as_bytes() { - b"admin" => Ok(Self::Admin), - b"user" => Ok(Self::User), - _ => Err("Unrecognized enum variant".into()), - } - } -} \ No newline at end of file