diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml index d33f143b..536f49a1 100644 --- a/.github/workflows/prod.yml +++ b/.github/workflows/prod.yml @@ -34,17 +34,25 @@ jobs: profile: minimal toolchain: stable + - name: Install cargo-nextest + uses: baptiste0928/cargo-install@v1 + with: + crate: cargo-nextest + args: --locked + # Uncomment the following line if you'd like to stay on the 0.9 series + # version: 0.9 + - name: Build and test app-service code working-directory: ./app-service run: | cargo build --verbose - cargo test --verbose + cargo nextest run - name: Build and test auth-service code working-directory: ./auth-service run: | cargo build --verbose - cargo test --verbose + cargo nextest run # Set up Docker Buildx for multi-platform builds - name: Set up Docker Buildx @@ -98,4 +106,4 @@ jobs: export AUTH_SERVICE_IP=${{ vars.DROPLET_IP }} docker compose down docker compose pull - docker compose up -d \ No newline at end of file + docker compose up -d --build \ No newline at end of file diff --git a/.github/workflows/ssl-config.yml b/.github/workflows/ssl-config.yml new file mode 100644 index 00000000..19d9084f --- /dev/null +++ b/.github/workflows/ssl-config.yml @@ -0,0 +1,52 @@ +name: SSL Config + +on: + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + env: + DOMAIN_NAME: lgr.aldass.dev + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Log in to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Install sshpass + run: sudo apt-get install sshpass + + - name: Copy nginx config for domain + run: sshpass -v -p ${{ secrets.DROPLET_PASSWORD }} scp -o StrictHostKeyChecking=no ./config/nginx/${{ env.DOMAIN_NAME }}.conf root@${{ vars.DROPLET_IP }}:/etc/nginx/sites-enabled/${{ env.DOMAIN_NAME }} + + - name: SSH Config + uses: appleboy/ssh-action@master + with: + host: ${{ vars.DROPLET_IP }} + username: root + password: ${{ secrets.DROPLET_PASSWORD }} + script: | + cd ~ + export AUTH_SERVICE_IP=${{ vars.DROPLET_IP }} + # docker compose down + # docker compose pull + # docker compose up -d --build + + apt update && sudo apt upgrade -y + apt install ca-certificates curl + + apt install certbot python3-certbot-nginx -y + ln -sf /etc/nginx/sites-available/${{ env.DOMAIN_NAME }} /etc/nginx/sites-enabled/ + certbot --nginx -d ${{ env.DOMAIN_NAME }} + nginx -t + systemctl restart nginx + certbot renew --dry-run + systemctl status certbot.timer + + ufw allow 'Nginx Full' + # ufw delete allow 'Nginx HTTP' diff --git a/app-service/Cargo.lock b/app-service/Cargo.lock index 89ab3cc7..6d65eb00 100644 --- a/app-service/Cargo.lock +++ b/app-service/Cargo.lock @@ -105,7 +105,7 @@ dependencies = [ "http 1.1.0", "http-body 1.0.1", "http-body-util", - "hyper 1.4.1", + "hyper 1.5.0", "hyper-util", "itoa", "matchit", @@ -457,9 +457,9 @@ dependencies = [ [[package]] name = "hyper" -version = "0.14.30" +version = "0.14.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a152ddd61dfaec7273fe8419ab357f33aee0d914c5f4efbf0d96fa749eea5ec9" +checksum = "8c08302e8fa335b151b788c775ff56e7a03ae64ff85c548ee820fecb70356e85" dependencies = [ "bytes", "futures-channel", @@ -481,9 +481,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" +checksum = "bbbff0a806a4728c99295b254c8838933b5b082d75e3cb70c8dab21fdfbcfa9a" dependencies = [ "bytes", "futures-channel", @@ -508,7 +508,7 @@ dependencies = [ "futures-util", "http 1.1.0", "http-body 1.0.1", - "hyper 1.4.1", + "hyper 1.5.0", "pin-project-lite", "tokio", "tower-service", @@ -557,9 +557,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.159" +version = "0.2.161" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" +checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" [[package]] name = "libm" @@ -727,9 +727,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "proc-macro2" -version = "1.0.87" +version = "1.0.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a" +checksum = "7c3a7fc5db1e57d5a779a352c8cdb57b29aa4c40cc69c3a68a7fedc815fbf2f9" dependencies = [ "unicode-ident", ] @@ -766,7 +766,7 @@ dependencies = [ "h2", "http 0.2.12", "http-body 0.4.6", - "hyper 0.14.30", + "hyper 0.14.31", "ipnet", "js-sys", "log", @@ -796,9 +796,9 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustversion" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" +checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" [[package]] name = "ryu" @@ -834,9 +834,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.128" +version = "1.0.131" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +checksum = "67d42a0bd4ac281beff598909bb56a86acaf979b84483e1c79c10dcaf98f8cf3" dependencies = [ "itoa", "memchr", @@ -1113,12 +1113,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "unicase" -version = "2.7.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" -dependencies = [ - "version_check", -] +checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df" [[package]] name = "unicode-bidi" diff --git a/auth-service/Cargo.lock b/auth-service/Cargo.lock index bc4564d1..dc40659f 100644 --- a/auth-service/Cargo.lock +++ b/auth-service/Cargo.lock @@ -17,6 +17,15 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "async-trait" version = "0.1.83" @@ -25,20 +34,25 @@ checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.79", ] [[package]] name = "auth-service" version = "0.1.0" dependencies = [ + "async-trait", "axum", + "fake", + "quickcheck", + "quickcheck_macros", "reqwest", "serde", "serde_json", "tokio", "tower-http", "uuid", + "validator", ] [[package]] @@ -141,6 +155,12 @@ version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.7.2" @@ -178,12 +198,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_logger" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" +dependencies = [ + "log", + "regex", +] + [[package]] name = "equivalent" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "fake" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6479fa2c7e83ddf8be7d435421e093b072ca891b99a49bc84eba098f4044f818" +dependencies = [ + "rand", +] + [[package]] name = "fnv" version = "1.0.7" @@ -238,6 +277,17 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.2.15" @@ -246,7 +296,7 @@ checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] @@ -419,6 +469,16 @@ dependencies = [ "tower-service", ] +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "0.5.0" @@ -460,6 +520,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.159" @@ -527,7 +593,7 @@ checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ "hermit-abi", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.52.0", ] @@ -587,6 +653,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.87" @@ -596,6 +671,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quickcheck" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44883e74aa97ad63db83c4bf8ca490f02b2fc02f92575e720c8551e843c945f" +dependencies = [ + "env_logger", + "log", + "rand", + "rand_core", +] + +[[package]] +name = "quickcheck_macros" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608c156fd8e97febc07dc9c2e2c80bf74cfc6ef26893eae3daf8bc2bc94a4b7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "quote" version = "1.0.37" @@ -605,6 +703,47 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha", + "rand_core", + "rand_hc", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core", +] + [[package]] name = "redox_syscall" version = "0.5.7" @@ -614,6 +753,35 @@ dependencies = [ "bitflags 2.6.0", ] +[[package]] +name = "regex" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + [[package]] name = "reqwest" version = "0.11.27" @@ -691,7 +859,7 @@ checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.79", ] [[package]] @@ -762,6 +930,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.79" @@ -847,7 +1026,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.79", ] [[package]] @@ -979,7 +1158,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", - "idna", + "idna 0.5.0", "percent-encoding", ] @@ -989,10 +1168,25 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" dependencies = [ - "getrandom", + "getrandom 0.2.15", "serde", ] +[[package]] +name = "validator" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b92f40481c04ff1f4f61f304d61793c7b56ff76ac1469f1beb199b1445b253bd" +dependencies = [ + "idna 0.4.0", + "lazy_static", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", +] + [[package]] name = "version_check" version = "0.9.5" @@ -1008,6 +1202,12 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1036,7 +1236,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn", + "syn 2.0.79", "wasm-bindgen-shared", ] @@ -1070,7 +1270,7 @@ checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.79", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1239,3 +1439,24 @@ dependencies = [ "cfg-if", "windows-sys 0.48.0", ] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] diff --git a/auth-service/Cargo.toml b/auth-service/Cargo.toml index 4817fe52..19d59187 100644 --- a/auth-service/Cargo.toml +++ b/auth-service/Cargo.toml @@ -12,6 +12,11 @@ tower-http = { version = "0.5.0", features = ["fs"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" uuid = { version = "1.7.0", features = ["v4", "serde"] } +async-trait = "0.1.78" +validator = "0.16.1" [dev-dependencies] -reqwest = { version = "0.11.26", default-features = false, features = ["json"] } \ No newline at end of file +reqwest = { version = "0.11.26", default-features = false, features = ["json"] } +fake = "=2.3.0" +quickcheck = "0.9.2" +quickcheck_macros = "0.9.1" diff --git a/auth-service/api_schema.yml b/auth-service/api_schema.yml index 107125d3..79a58e14 100644 --- a/auth-service/api_schema.yml +++ b/auth-service/api_schema.yml @@ -6,8 +6,13 @@ info: version: 1.0.0 servers: - - url: 'http://example.com/api' - description: Main server + - url: 'http://localhost:3000/api' + description: Main server local debug + - url: 'http://67.205.177.162:3000/api' + description: Main server DigitalOcean via IP + - url: 'http://lgr.aldass.dev:3000/api' + description: Main server DigitalOcean via domain name + paths: /: diff --git a/auth-service/src/app_state.rs b/auth-service/src/app_state.rs new file mode 100644 index 00000000..1091d777 --- /dev/null +++ b/auth-service/src/app_state.rs @@ -0,0 +1,17 @@ +use std::sync::Arc; +use tokio::sync::RwLock; + +use crate::domain::UserStore; + +pub type UserStoreType = Arc>; + +#[derive(Clone)] +pub struct AppState { + pub user_store: UserStoreType, +} + +impl AppState { + pub fn new(user_store: UserStoreType) -> Self { + Self { user_store } + } +} diff --git a/auth-service/src/domain/data_stores.rs b/auth-service/src/domain/data_stores.rs new file mode 100644 index 00000000..a625d058 --- /dev/null +++ b/auth-service/src/domain/data_stores.rs @@ -0,0 +1,17 @@ +use super::{Email, Password, User}; + +#[async_trait::async_trait] +pub trait UserStore { + async fn add_user(&mut self, user: User) -> Result<(), UserStoreError>; + async fn get_user(&self, email: &Email) -> Result; + async fn validate_user(&self, email: &Email, password: &Password) + -> Result<(), UserStoreError>; +} + +#[derive(Debug, PartialEq)] +pub enum UserStoreError { + UserAlreadyExists, + UserNotFound, + InvalidCredentials, + UnexpectedError, +} diff --git a/auth-service/src/domain/email.rs b/auth-service/src/domain/email.rs new file mode 100644 index 00000000..61092a14 --- /dev/null +++ b/auth-service/src/domain/email.rs @@ -0,0 +1,59 @@ +use validator::validate_email; + +#[derive(Debug, Clone, PartialEq, Hash, Eq)] +pub struct Email(String); + +impl Email { + pub fn parse(s: String) -> Result { + if validate_email(&s) { + Ok(Self(s)) + } else { + Err(format!("{} is not a valid email.", s)) + } + } +} + +impl AsRef for Email { + fn as_ref(&self) -> &str { + &self.0 + } +} + +#[cfg(test)] +mod tests { + use super::Email; + + use fake::faker::internet::en::SafeEmail; + use fake::Fake; + + #[test] + fn empty_string_is_rejected() { + let email = "".to_string(); + assert!(Email::parse(email).is_err()); + } + #[test] + fn email_missing_at_symbol_is_rejected() { + let email = "ursuladomain.com".to_string(); + assert!(Email::parse(email).is_err()); + } + #[test] + fn email_missing_subject_is_rejected() { + let email = "@domain.com".to_string(); + assert!(Email::parse(email).is_err()); + } + + #[derive(Debug, Clone)] + struct ValidEmailFixture(pub String); + + impl quickcheck::Arbitrary for ValidEmailFixture { + fn arbitrary(g: &mut G) -> Self { + let email = SafeEmail().fake_with_rng(g); + Self(email) + } + } + + #[quickcheck_macros::quickcheck] + fn valid_emails_are_parsed_successfully(valid_email: ValidEmailFixture) -> bool { + Email::parse(valid_email.0).is_ok() + } +} diff --git a/auth-service/src/domain/error.rs b/auth-service/src/domain/error.rs new file mode 100644 index 00000000..a2588dd3 --- /dev/null +++ b/auth-service/src/domain/error.rs @@ -0,0 +1,5 @@ +pub enum AuthAPIError { + UserAlreadyExists, + InvalidCredentials, + UnexpectedError, +} diff --git a/auth-service/src/domain/mod.rs b/auth-service/src/domain/mod.rs new file mode 100644 index 00000000..b08c3dff --- /dev/null +++ b/auth-service/src/domain/mod.rs @@ -0,0 +1,11 @@ +pub mod data_stores; +pub mod email; +pub mod password; +pub mod error; +pub mod user; + +pub use data_stores::*; +pub use email::*; +pub use error::*; +pub use user::*; +pub use password::*; diff --git a/auth-service/src/domain/password.rs b/auth-service/src/domain/password.rs new file mode 100644 index 00000000..5adac01f --- /dev/null +++ b/auth-service/src/domain/password.rs @@ -0,0 +1,55 @@ +#[derive(Debug, Clone, PartialEq)] +pub struct Password(String); + +impl Password { + pub fn parse(s: String) -> Result { + if validate_password(&s) { + Ok(Self(s)) + } else { + Err("Failed to parse string to a Password type".to_owned()) + } + } +} + +fn validate_password(s: &str) -> bool { + s.len() >= 8 +} + +impl AsRef for Password { + fn as_ref(&self) -> &str { + &self.0 + } +} + +#[cfg(test)] +mod tests { + use super::Password; + + use fake::faker::internet::en::Password as FakePassword; + use fake::Fake; + + #[test] + fn empty_string_is_rejected() { + let password = "".to_owned(); + assert!(Password::parse(password).is_err()); + } + #[test] + fn string_less_than_8_characters_is_rejected() { + let password = "1234567".to_owned(); + assert!(Password::parse(password).is_err()); + } + + #[derive(Debug, Clone)] + struct ValidPasswordFixture(pub String); + + impl quickcheck::Arbitrary for ValidPasswordFixture { + fn arbitrary(g: &mut G) -> Self { + let password = FakePassword(8..30).fake_with_rng(g); + Self(password) + } + } + #[quickcheck_macros::quickcheck] + fn valid_passwords_are_parsed_successfully(valid_password: ValidPasswordFixture) -> bool { + Password::parse(valid_password.0).is_ok() + } +} diff --git a/auth-service/src/domain/user.rs b/auth-service/src/domain/user.rs new file mode 100644 index 00000000..0125458e --- /dev/null +++ b/auth-service/src/domain/user.rs @@ -0,0 +1,18 @@ +use super::{Email, Password}; + +#[derive(Clone, Debug, PartialEq)] +pub struct User { + pub email: Email, + pub password: Password, + pub requires_2fa: bool, +} + +impl User { + pub fn new(email: Email, password: Password, requires_2fa: bool) -> Self { + Self { + email, + password, + requires_2fa, + } + } +} diff --git a/auth-service/src/lib.rs b/auth-service/src/lib.rs index 5efd99cd..fd13b409 100644 --- a/auth-service/src/lib.rs +++ b/auth-service/src/lib.rs @@ -1,10 +1,22 @@ use std::error::Error; -use axum::{routing::post, serve::Serve, Router}; +use app_state::AppState; +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + routing::post, + serve::Serve, + Json, Router, +}; +use domain::AuthAPIError; use routes::{login, logout, signup, verify_2fa, verify_token}; +use serde::{Deserialize, Serialize}; use tower_http::services::ServeDir; -mod routes; +pub mod app_state; +pub mod domain; +pub mod routes; +pub mod services; pub struct Application { server: Serve, @@ -12,14 +24,15 @@ pub struct Application { } impl Application { - pub async fn build(address: &str) -> Result> { + pub async fn build(app_state: AppState, address: &str) -> Result> { let router = Router::new() .nest_service("/", ServeDir::new("assets")) .route("/signup", post(signup)) .route("/login", post(login)) .route("/logout", post(logout)) .route("/verify-2fa", post(verify_2fa)) - .route("/verify-token", post(verify_token)); + .route("/verify-token", post(verify_token)) + .with_state(app_state); let listener = tokio::net::TcpListener::bind(address).await?; let address = listener.local_addr()?.to_string(); @@ -33,3 +46,24 @@ impl Application { self.server.await } } + +#[derive(Serialize, Deserialize)] +pub struct ErrorResponse { + pub error: String, +} + +impl IntoResponse for AuthAPIError { + fn into_response(self) -> Response { + let (status, error_message) = match self { + AuthAPIError::UserAlreadyExists => (StatusCode::CONFLICT, "User already exists"), + AuthAPIError::InvalidCredentials => (StatusCode::BAD_REQUEST, "Invalid credentials"), + AuthAPIError::UnexpectedError => { + (StatusCode::INTERNAL_SERVER_ERROR, "Unexpected error") + } + }; + let body = Json(ErrorResponse { + error: error_message.to_string(), + }); + (status, body).into_response() + } +} diff --git a/auth-service/src/main.rs b/auth-service/src/main.rs index 8cd771e5..6ac9c795 100644 --- a/auth-service/src/main.rs +++ b/auth-service/src/main.rs @@ -1,8 +1,16 @@ -use auth_service::Application; +use std::sync::Arc; +use tokio::sync::RwLock; + +use auth_service::{ + app_state::AppState, services::hashmap_user_store::HashMapUserStore, Application, +}; #[tokio::main] async fn main() { - let app = Application::build("0.0.0.0:3000") + let user_store = Arc::new(RwLock::new(HashMapUserStore::default())); + let app_state = AppState::new(user_store); + + let app = Application::build(app_state, "0.0.0.0:3000") .await .expect("Failed to build app"); diff --git a/auth-service/src/routes/signup.rs b/auth-service/src/routes/signup.rs index 613d20a4..902b1fbc 100644 --- a/auth-service/src/routes/signup.rs +++ b/auth-service/src/routes/signup.rs @@ -1,8 +1,37 @@ -use axum::{http::StatusCode, response::IntoResponse, Json}; -use serde::Deserialize; +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use serde::{Deserialize, Serialize}; -pub async fn signup(Json(request): Json) -> impl IntoResponse { - StatusCode::OK.into_response() +use crate::{ + app_state::AppState, + domain::{AuthAPIError, Email, Password, User}, +}; + +pub async fn signup( + State(state): State, + Json(request): Json, +) -> Result { + let email = + Email::parse(request.email.clone()).map_err(|_| AuthAPIError::InvalidCredentials)?; + let password = + Password::parse(request.password.clone()).map_err(|_| AuthAPIError::InvalidCredentials)?; + + let user = User::new(email, password, request.requires_2fa); + + let mut user_store = state.user_store.write().await; + + if user_store.get_user(&user.email).await.is_ok() { + return Err(AuthAPIError::UserAlreadyExists); + } + + if user_store.add_user(user).await.is_err() { + return Err(AuthAPIError::UnexpectedError); + } + + let response = Json(SignupResponse { + message: "User created successfully!".to_string(), + }); + + Ok((StatusCode::CREATED, response)) } #[derive(Deserialize)] @@ -12,3 +41,8 @@ pub struct SignupRequest { #[serde(rename = "requires2FA")] pub requires_2fa: bool, } + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct SignupResponse { + pub message: String, +} diff --git a/auth-service/src/services/hashmap_user_store.rs b/auth-service/src/services/hashmap_user_store.rs new file mode 100644 index 00000000..3d07e552 --- /dev/null +++ b/auth-service/src/services/hashmap_user_store.rs @@ -0,0 +1,123 @@ +use std::collections::HashMap; + +use crate::domain::{Email, Password, User, UserStore, UserStoreError}; + +#[derive(Default)] +pub struct HashMapUserStore { + users: HashMap, +} + +#[async_trait::async_trait] +impl UserStore for HashMapUserStore { + async fn add_user(&mut self, user: User) -> Result<(), UserStoreError> { + if self.users.contains_key(&user.email) { + return Err(UserStoreError::UserAlreadyExists); + } + self.users.insert(user.email.clone(), user); + Ok(()) + } + + async fn get_user(&self, email: &Email) -> Result { + match self.users.get(email) { + Some(user) => Ok(user.clone()), + None => Err(UserStoreError::UserNotFound), + } + } + + async fn validate_user( + &self, + email: &Email, + password: &Password, + ) -> Result<(), UserStoreError> { + match self.users.get(email) { + Some(user) => { + if user.password.eq(password) { + Ok(()) + } else { + Err(UserStoreError::InvalidCredentials) + } + } + None => Err(UserStoreError::UserNotFound), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_add_user() { + let mut user_store = HashMapUserStore::default(); + let user = User { + email: Email::parse("test@example.com".to_owned()).unwrap(), + password: Password::parse("password".to_owned()).unwrap(), + requires_2fa: false, + }; + + // Test adding a new user + let result = user_store.add_user(user.clone()).await; + assert!(result.is_ok()); + + // Test adding an existing user + let result = user_store.add_user(user).await; + assert_eq!(result, Err(UserStoreError::UserAlreadyExists)); + } + + #[tokio::test] + async fn test_get_user() { + let mut user_store = HashMapUserStore::default(); + let email = Email::parse("test@example.com".to_owned()).unwrap(); + + let user = User { + email: email.clone(), + password: Password::parse("password".to_owned()).unwrap(), + requires_2fa: false, + }; + + // Test getting a user that exists + user_store.users.insert(email.clone(), user.clone()); + let result = user_store.get_user(&email).await; + assert_eq!(result, Ok(user)); + + // Test getting a user that doesn't exist + let result = user_store + .get_user(&Email::parse("nonexistent@example.com".to_owned()).unwrap()) + .await; + + assert_eq!(result, Err(UserStoreError::UserNotFound)); + } + + #[tokio::test] + async fn test_validate_user() { + let mut user_store = HashMapUserStore::default(); + let email = Email::parse("test@example.com".to_owned()).unwrap(); + let password = Password::parse("password".to_owned()).unwrap(); + + let user = User { + email: email.clone(), + password: password.clone(), + requires_2fa: false, + }; + + // Test validating a user that exists with correct password + user_store.users.insert(email.clone(), user.clone()); + let result = user_store.validate_user(&email, &password).await; + assert_eq!(result, Ok(())); + + // Test validating a user that exists with incorrect password + let wrong_password = Password::parse("wrongpassword".to_owned()).unwrap(); + let result = user_store.validate_user(&email, &wrong_password).await; + assert_eq!(result, Err(UserStoreError::InvalidCredentials)); + + // Test validating a user that doesn't exist + let result = user_store + .validate_user( + &Email::parse("nonexistent@example.com".to_string()).unwrap(), + &password, + ) + .await; + + assert_eq!(result, Err(UserStoreError::UserNotFound)); + } +} diff --git a/auth-service/src/services/mod.rs b/auth-service/src/services/mod.rs new file mode 100644 index 00000000..567ac655 --- /dev/null +++ b/auth-service/src/services/mod.rs @@ -0,0 +1 @@ +pub mod hashmap_user_store; diff --git a/auth-service/tests/api/helpers.rs b/auth-service/tests/api/helpers.rs index 28a6f81f..4941012c 100644 --- a/auth-service/tests/api/helpers.rs +++ b/auth-service/tests/api/helpers.rs @@ -1,4 +1,10 @@ -use auth_service::Application; +use std::sync::Arc; +use tokio::sync::RwLock; + +use auth_service::{ + app_state::AppState, services::hashmap_user_store::HashMapUserStore, Application, +}; + use uuid::Uuid; pub struct TestApp { @@ -8,7 +14,10 @@ pub struct TestApp { impl TestApp { pub async fn new() -> Self { - let app = Application::build("127.0.0.1:0") + let user_store = Arc::new(RwLock::new(HashMapUserStore::default())); + let app_state = AppState::new(user_store); + + let app = Application::build(app_state, "127.0.0.1:0") .await .expect("Failed to build app"); diff --git a/auth-service/tests/api/signup.rs b/auth-service/tests/api/signup.rs index ed264551..1608190a 100644 --- a/auth-service/tests/api/signup.rs +++ b/auth-service/tests/api/signup.rs @@ -1,5 +1,115 @@ +use auth_service::{routes::SignupResponse, ErrorResponse}; + use crate::helpers::{get_random_email, TestApp}; +#[tokio::test] +async fn should_return_201_if_valid_input() { + let app = TestApp::new().await; + + let random_email = get_random_email(); + + let signup_body = serde_json::json!({ + "email": random_email, + "password": "password123", + "requires2FA": true + }); + + let response = app.post_signup(&signup_body).await; + + assert_eq!(response.status().as_u16(), 201); + + let expected_response = SignupResponse { + message: "User created successfully!".to_owned(), + }; + + assert_eq!( + response + .json::() + .await + .expect("Could not deserialize response body to UserBody"), + expected_response + ); +} + +#[tokio::test] +async fn should_return_400_if_invalid_input() { + let app = TestApp::new().await; + + let random_email = get_random_email(); + + let input = [ + serde_json::json!({ + "email": "", + "password": "password123", + "requires2FA": true + }), + serde_json::json!({ + "email": random_email, + "password": "", + "requires2FA": true + }), + serde_json::json!({ + "email": "", + "password": "", + "requires2FA": true + }), + serde_json::json!({ + "email": "invalid_email", + "password": "password123", + "requires2FA": true + }), + serde_json::json!({ + "email": random_email, + "password": "invalid", + "requires2FA": true + }), + ]; + + for i in input.iter() { + let response = app.post_signup(i).await; + assert_eq!(response.status().as_u16(), 400, "Failed for input: {:?}", i); + + assert_eq!( + response + .json::() + .await + .expect("Could not deserialize response body to ErrorResponse") + .error, + "Invalid credentials".to_owned() + ); + } +} + +#[tokio::test] +async fn should_return_409_if_email_already_exists() { + let app = TestApp::new().await; + + let random_email = get_random_email(); + + let signup_body = serde_json::json!({ + "email": random_email, + "password": "password123", + "requires2FA": true + }); + + let response = app.post_signup(&signup_body).await; + + assert_eq!(response.status().as_u16(), 201); + + let response = app.post_signup(&signup_body).await; + + assert_eq!(response.status().as_u16(), 409); + + assert_eq!( + response + .json::() + .await + .expect("Could not deserialize response body to ErrorResponse") + .error, + "User already exists".to_owned() + ); +} + #[tokio::test] async fn should_return_422_if_malformed_input() { let app = TestApp::new().await; diff --git a/config/nginx/lgr.aldass.dev.conf b/config/nginx/lgr.aldass.dev.conf new file mode 100644 index 00000000..9970f375 --- /dev/null +++ b/config/nginx/lgr.aldass.dev.conf @@ -0,0 +1,32 @@ +server { + listen 80; + server_name lgr.aldass.dev; + return 301 https://$server_name$request_uri; + + # Redirect HTTP to HTTPS only for the specific path + location /api/ { + return 301 https://$server_name$request_uri; + } +} + +server { + listen 443 ssl; + server_name lgr.aldass.dev; + + ssl_certificate /etc/letsencrypt/live/lgr.aldass.dev/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/lgr.aldass.dev/privkey.pem; + + location /api { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + # # Optionally, redirect other HTTPS requests to HTTP + # location / { + # return 301 http://$server_name$request_uri; + # } +}