From 0137b61d8cebdd08a65a4d80f5b4cd4061bcad8d Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Mon, 17 Nov 2025 22:48:02 +0530 Subject: [PATCH 01/21] feat(auth): add dependencies and configuration for authentication --- .env.sample | 10 +- .gitignore | 1 + Cargo.lock | 410 +++++++++++++++++++++++++++++++++++++++++++++------- Cargo.toml | 4 + 4 files changed, 371 insertions(+), 54 deletions(-) diff --git a/.env.sample b/.env.sample index ae067fa..819320b 100644 --- a/.env.sample +++ b/.env.sample @@ -6,9 +6,15 @@ POSTGRES_HOST=localhost # Root env ROOT_DB_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB} -RUST_ENV=development +ROOT_ENV=development ROOT_SECRET=insecuresecret123 # Used to verify origin of attendance mutations ROOT_PORT=3000 +# GitHub OAuth for authentication +GITHUB_CLIENT_ID=your_github_oauth_app_client_id +GITHUB_CLIENT_SECRET=your_github_oauth_app_client_secret +GITHUB_REDIRECT_URL=http://localhost:5000/auth/github/callback # Call back to frontend +GITHUB_ORG_NAME=amfoss + # Seed toggle -SEEDING_ENABLED=false \ No newline at end of file +SEEDING_ENABLED=false diff --git a/.gitignore b/.gitignore index c12db7a..2da9a40 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ Secrets*.toml backups/ .env *.log +.aider* diff --git a/Cargo.lock b/Cargo.lock index 8c0e323..415569e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,7 +82,7 @@ dependencies = [ "futures-timer", "futures-util", "handlebars", - "http", + "http 1.2.0", "indexmap", "mime", "multer", @@ -220,10 +220,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.6.0", "hyper-util", "itoa", "matchit", @@ -236,7 +236,7 @@ dependencies = [ "serde_path_to_error", "serde_urlencoded", "sha1", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-tungstenite", "tower", @@ -253,12 +253,12 @@ checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tower-layer", "tower-service", "tracing", @@ -279,6 +279,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -297,6 +303,25 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "bcrypt" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" +dependencies = [ + "base64 0.22.1", + "blowfish", + "getrandom 0.2.15", + "subtle", + "zeroize", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.8.0" @@ -315,6 +340,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bumpalo" version = "3.17.0" @@ -360,6 +395,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link 0.2.0", ] @@ -374,6 +410,16 @@ dependencies = [ "phf", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -850,8 +896,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -872,6 +920,25 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.8" @@ -883,7 +950,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.2.0", "indexmap", "slab", "tokio", @@ -970,6 +1037,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.2.0" @@ -981,6 +1059,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -988,7 +1077,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.2.0", ] [[package]] @@ -999,8 +1088,8 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1016,6 +1105,30 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.6.0" @@ -1025,9 +1138,9 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.8", + "http 1.2.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1037,6 +1150,20 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.5" @@ -1044,13 +1171,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" dependencies = [ "futures-util", - "http", - "hyper", + "http 1.2.0", + "hyper 1.6.0", "hyper-util", - "rustls", + "rustls 0.23.23", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.1", "tower-service", ] @@ -1062,7 +1189,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.6.0", "hyper-util", "native-tls", "tokio", @@ -1081,15 +1208,15 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", - "http-body", - "hyper", + "http 1.2.0", + "http-body 1.0.1", + "hyper 1.6.0", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", - "system-configuration", + "socket2 0.6.0", + "system-configuration 0.6.1", "tokio", "tower-service", "tracing", @@ -1275,13 +1402,22 @@ dependencies = [ "serde", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "io-uring" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags", + "bitflags 2.8.0", "cfg-if", "libc", ] @@ -1454,7 +1590,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http", + "http 1.2.0", "httparse", "memchr", "mime", @@ -1541,6 +1677,26 @@ dependencies = [ "libm", ] +[[package]] +name = "oauth2" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" +dependencies = [ + "base64 0.13.1", + "chrono", + "getrandom 0.2.15", + "http 0.2.12", + "rand 0.8.5", + "reqwest 0.11.27", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + [[package]] name = "object" version = "0.36.7" @@ -1562,7 +1718,7 @@ version = "0.10.71" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e14130c6a98cd258fdcb0fb6d744152343ff729cbfcb28c656a9d12b999fbcd" dependencies = [ - "bitflags", + "bitflags 2.8.0", "cfg-if", "foreign-types", "libc", @@ -1871,7 +2027,7 @@ version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82b568323e98e49e2a0899dcee453dd679fae22d69adf9b11dd508d1549b7e2f" dependencies = [ - "bitflags", + "bitflags 2.8.0", ] [[package]] @@ -1903,6 +2059,47 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-rustls 0.24.1", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + [[package]] name = "reqwest" version = "0.12.23" @@ -1913,12 +2110,12 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.8", + "http 1.2.0", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.6.0", + "hyper-rustls 0.27.5", "hyper-tls", "hyper-util", "js-sys", @@ -1931,7 +2128,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", "tower", @@ -1964,7 +2161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" dependencies = [ "base64 0.21.7", - "bitflags", + "bitflags 2.8.0", "serde", "serde_derive", ] @@ -1975,14 +2172,18 @@ version = "2.0.0" dependencies = [ "async-graphql", "async-graphql-axum", + "async-trait", "axum", + "bcrypt", "chrono", "chrono-tz", "config", "dotenv", "hex", "hmac", - "reqwest", + "oauth2", + "rand 0.8.5", + "reqwest 0.12.23", "serde", "serde_json", "sha2", @@ -2037,13 +2238,25 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.8.0", "errno", "libc", "linux-raw-sys", "windows-sys 0.59.0", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.23" @@ -2052,17 +2265,36 @@ checksum = "47796c98c480fce5406ef69d1c76378375492c3b0a0de587be0c1d9feb12f395" dependencies = [ "once_cell", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.102.8", "subtle", "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pki-types" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.102.8" @@ -2101,13 +2333,23 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "security-framework" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.8.0", "core-foundation", "core-foundation-sys", "libc", @@ -2281,6 +2523,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.0" @@ -2404,7 +2656,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags", + "bitflags 2.8.0", "byteorder", "bytes", "chrono", @@ -2447,7 +2699,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags", + "bitflags 2.8.0", "byteorder", "chrono", "crc", @@ -2570,6 +2822,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2590,15 +2848,36 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys 0.5.0", +] + [[package]] name = "system-configuration" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags", + "bitflags 2.8.0", "core-foundation", - "system-configuration-sys", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -2753,7 +3032,7 @@ dependencies = [ "mio", "pin-project-lite", "slab", - "socket2", + "socket2 0.6.0", "tokio-macros", "windows-sys 0.59.0", ] @@ -2779,13 +3058,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" dependencies = [ - "rustls", + "rustls 0.23.23", "tokio", ] @@ -2883,7 +3172,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -2896,11 +3185,11 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags", + "bitflags 2.8.0", "bytes", "futures-util", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -2997,7 +3286,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.2.0", "httparse", "log", "rand 0.9.0", @@ -3072,6 +3361,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -3221,6 +3511,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + [[package]] name = "whoami" version = "1.5.2" @@ -3438,13 +3734,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen-rt" version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" dependencies = [ - "bitflags", + "bitflags 2.8.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ddf26b8..63667ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,7 @@ tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["env-filter", "time", "fmt", "std"] } dotenv = "0.15.0" time = { version = "0.3.44", features = ["formatting"] } +oauth2 = "4.4.2" +bcrypt = "0.15.1" +rand = "0.8.5" +async-trait = "0.1.83" From 5d213a69271cde9b59965b1472b70b1ca3e8e245 Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Mon, 17 Nov 2025 22:54:11 +0530 Subject: [PATCH 02/21] feat(models): add auth models and update member table --- .../20251114165724_create_auth_system.sql | 48 ++++++++++++++++ src/models/auth.rs | 55 +++++++++++++++++++ src/models/member.rs | 21 ++++--- src/models/mod.rs | 1 + 4 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 migrations/20251114165724_create_auth_system.sql create mode 100644 src/models/auth.rs diff --git a/migrations/20251114165724_create_auth_system.sql b/migrations/20251114165724_create_auth_system.sql new file mode 100644 index 0000000..9f15d79 --- /dev/null +++ b/migrations/20251114165724_create_auth_system.sql @@ -0,0 +1,48 @@ +-- Create authentication system tables + +-- Custom type for user roles +CREATE TYPE role_type AS ENUM ('Admin', 'Member', 'Bot'); + +-- Add role column to Member table +ALTER TABLE Member ADD COLUMN role role_type NOT NULL DEFAULT 'Member'; + +-- Make member fields nullable for GitHub-only registrations +ALTER TABLE Member ALTER COLUMN roll_no DROP NOT NULL; +ALTER TABLE Member ALTER COLUMN sex DROP NOT NULL; +ALTER TABLE Member ALTER COLUMN year DROP NOT NULL; +ALTER TABLE Member ALTER COLUMN hostel DROP NOT NULL; +ALTER TABLE Member ALTER COLUMN mac_address DROP NOT NULL; +ALTER TABLE Member ALTER COLUMN discord_id DROP NOT NULL; +ALTER TABLE Member ALTER COLUMN group_id DROP NOT NULL; + +-- Add updated_at column to Member table +ALTER TABLE Member ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT NOW(); + +-- Sessions table: stores session tokens for logged-in users +CREATE TABLE IF NOT EXISTS Sessions ( + session_id SERIAL PRIMARY KEY, + member_id INTEGER NOT NULL REFERENCES Member(member_id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- API Keys table: stores hashed API keys for bot authentication +CREATE TABLE IF NOT EXISTS ApiKeys ( + api_key_id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + key_hash TEXT NOT NULL, + created_by INTEGER REFERENCES Member(member_id) ON DELETE SET NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMP +); + +-- Few critical indexes +CREATE INDEX idx_sessions_token_hash ON Sessions(token_hash); +CREATE INDEX idx_apikeys_key_hash ON ApiKeys(key_hash); + +-- Trigger to update timestamp on Users table +CREATE TRIGGER update_users_timestamp +BEFORE UPDATE ON Member +FOR EACH ROW +EXECUTE FUNCTION update_timestamp(); diff --git a/src/models/auth.rs b/src/models/auth.rs new file mode 100644 index 0000000..3d3a6f0 --- /dev/null +++ b/src/models/auth.rs @@ -0,0 +1,55 @@ +use crate::models::member::Member; +use async_graphql::{Enum, SimpleObject}; +use chrono::NaiveDateTime; +use sqlx::FromRow; + +#[derive(Enum, Copy, Clone, Eq, PartialEq, sqlx::Type, Debug)] +#[sqlx(type_name = "role_type")] +pub enum Role { + Admin, + Member, + Bot, +} + +#[derive(SimpleObject, FromRow, Debug)] +pub struct Session { + pub session_id: i32, + pub member_id: i32, + #[graphql(skip)] + pub token_hash: String, + pub expires_at: NaiveDateTime, + #[graphql(skip)] + pub created_at: NaiveDateTime, +} + +#[derive(SimpleObject, FromRow, Debug)] +pub struct ApiKey { + pub api_key_id: i32, + pub name: String, + #[graphql(skip)] + pub key_hash: String, + pub created_by: Option, + pub created_at: NaiveDateTime, + pub last_used_at: Option, +} + +// Response types for auth mutations +#[derive(SimpleObject)] +pub struct AuthResponse { + pub member: Member, + pub session_token: String, +} + +#[derive(SimpleObject)] +pub struct ApiKeyResponse { + pub api_key: String, +} + +// OAuth callback data (not an input, used internally) +#[derive(Debug, Clone)] +pub struct GitHubUser { + pub github_id: i64, + pub github_username: String, + pub name: String, + pub email: String, +} diff --git a/src/models/member.rs b/src/models/member.rs index 4fdfed5..45a701f 100644 --- a/src/models/member.rs +++ b/src/models/member.rs @@ -1,8 +1,9 @@ +use crate::models::auth::Role; use async_graphql::{Enum, InputObject, SimpleObject}; use chrono::NaiveDateTime; use sqlx::FromRow; -#[derive(Enum, Copy, Clone, Eq, PartialEq, sqlx::Type)] +#[derive(Enum, Copy, Clone, Eq, PartialEq, sqlx::Type, Debug)] #[sqlx(type_name = "sex_type")] pub enum Sex { M, @@ -10,22 +11,24 @@ pub enum Sex { Other, } -#[derive(SimpleObject, FromRow)] +#[derive(SimpleObject, FromRow, Clone, Debug)] #[graphql(complex)] pub struct Member { pub member_id: i32, - pub roll_no: String, + pub roll_no: Option, pub name: String, pub email: String, - pub sex: Sex, - pub year: i32, - pub hostel: String, - pub mac_address: String, - pub discord_id: String, - pub group_id: i32, + pub sex: Option, + pub year: Option, + pub hostel: Option, + pub mac_address: Option, + pub discord_id: Option, + pub group_id: Option, + pub role: Role, pub track: Option, pub github_user: Option, pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, } #[derive(InputObject)] diff --git a/src/models/mod.rs b/src/models/mod.rs index f0a4582..3dcf6d7 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,4 @@ pub mod attendance; +pub mod auth; pub mod member; pub mod status_update; From 2fc8deeaacf8f4e9a2b59a7ee74fcde2a0ff974e Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Mon, 17 Nov 2025 22:55:22 +0530 Subject: [PATCH 03/21] feat(auth): implement session management system and authentication context --- src/auth/mod.rs | 44 +++++++++++++++++ src/auth/session.rs | 115 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 src/auth/mod.rs create mode 100644 src/auth/session.rs diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..c38626b --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,44 @@ +pub mod api_key; +pub mod guards; +pub mod middleware; +pub mod oauth; +pub mod session; + +use crate::models::auth::Role; +use crate::models::member::Member; + +// Context for authenticated requests +#[derive(Clone, Debug)] +pub struct AuthContext { + pub user: Option, +} + +impl AuthContext { + pub fn new(user: Option) -> Self { + Self { user } + } + + pub fn user(&self) -> Option<&Member> { + self.user.as_ref() + } + + pub fn role(&self) -> Option { + self.user.as_ref().map(|u| u.role) + } + + pub fn has_role(&self, role: Role) -> bool { + self.role() == Some(role) + } + + pub fn is_authenticated(&self) -> bool { + self.user.is_some() + } + + pub fn is_admin(&self) -> bool { + self.has_role(Role::Admin) + } + + pub fn is_bot(&self) -> bool { + self.has_role(Role::Bot) + } +} diff --git a/src/auth/session.rs b/src/auth/session.rs new file mode 100644 index 0000000..cbfe201 --- /dev/null +++ b/src/auth/session.rs @@ -0,0 +1,115 @@ +use crate::models::member::Member; +use chrono::{Duration, Utc}; +use chrono_tz::Asia::Kolkata; +use rand::Rng; +use sha2::{Digest, Sha256}; +use sqlx::PgPool; + +const SESSION_DURATION_DAYS: i64 = 30; +const TOKEN_LENGTH: usize = 64; + +pub struct SessionService; + +impl SessionService { + /// Generate a random session token + fn generate_token() -> String { + let mut rng = rand::thread_rng(); + let token: String = (0..TOKEN_LENGTH) + .map(|_| { + let idx = rng.gen_range(0..62); + match idx { + 0..=25 => (b'A' + idx) as char, + 26..=51 => (b'a' + (idx - 26)) as char, + _ => (b'0' + (idx - 52)) as char, + } + }) + .collect(); + token + } + + /// Hash a token using SHA-256 + fn hash_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + format!("{:x}", hasher.finalize()) + } + + /// Create a new session for a member + pub async fn create_session(pool: &PgPool, member_id: i32) -> Result { + let token = Self::generate_token(); + let token_hash = Self::hash_token(&token); + let expires_at = Utc::now().with_timezone(&Kolkata) + Duration::days(SESSION_DURATION_DAYS); + + sqlx::query( + r#" + INSERT INTO Sessions (member_id, token_hash, expires_at) + VALUES ($1, $2, $3) + "#, + ) + .bind(member_id) + .bind(token_hash) + .bind(expires_at) + .execute(pool) + .await + .map_err(|e| format!("Failed to create session: {}", e))?; + + Ok(token) + } + + /// Validate a session token and return the associated member + pub async fn validate_session(pool: &PgPool, token: &str) -> Result, String> { + let token_hash = Self::hash_token(token); + let now = chrono::Utc::now().with_timezone(&Kolkata); + + let result = sqlx::query_as::<_, Member>( + r#" + SELECT m.* FROM Member m + INNER JOIN Sessions s ON m.member_id = s.member_id + WHERE s.token_hash = $1 AND s.expires_at > $2 + "#, + ) + .bind(token_hash) + .bind(now) + .fetch_optional(pool) + .await + .map_err(|e| format!("Failed to validate session: {}", e))?; + + Ok(result) + } + + /// Delete a session by its token + pub async fn delete_session_by_token(pool: &PgPool, token: &str) -> Result<(), String> { + let token_hash = Self::hash_token(token); + + sqlx::query( + r#" + DELETE FROM Sessions + WHERE token_hash = $1 + "#, + ) + .bind(token_hash) + .execute(pool) + .await + .map_err(|e| format!("Failed to delete session: {}", e))?; + + Ok(()) + } + + /// Clean up expired sessions (should be run periodically) + pub async fn cleanup_expired_sessions(pool: &PgPool) -> Result { + let now = chrono::Utc::now().with_timezone(&Kolkata); + + let result = sqlx::query( + r#" + DELETE FROM Sessions + WHERE expires_at <= $1 + "#, + ) + .bind(now) + .execute(pool) + .await + .map_err(|e| format!("Failed to cleanup sessions: {}", e))?; + + Ok(result.rows_affected()) + } +} From 6251ccdf24a2de821cb982b52a1edcbac47616b9 Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Mon, 17 Nov 2025 22:55:47 +0530 Subject: [PATCH 04/21] feat(auth): implement API key authentication --- src/auth/api_key.rs | 154 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 src/auth/api_key.rs diff --git a/src/auth/api_key.rs b/src/auth/api_key.rs new file mode 100644 index 0000000..7360f64 --- /dev/null +++ b/src/auth/api_key.rs @@ -0,0 +1,154 @@ +use crate::models::auth::{ApiKey, Role}; +use crate::models::member::Member; +use bcrypt::{hash, verify, DEFAULT_COST}; +use chrono_tz::Asia::Kolkata; +use rand::Rng; +use sqlx::PgPool; + +const API_KEY_LENGTH: usize = 48; +const API_KEY_PREFIX: &str = "root_"; + +pub struct ApiKeyService; + +impl ApiKeyService { + /// Generate a random API key with prefix + fn generate_api_key() -> String { + let mut rng = rand::thread_rng(); + let key: String = (0..API_KEY_LENGTH) + .map(|_| { + let idx = rng.gen_range(0..62); + match idx { + 0..=25 => (b'A' + idx) as char, + 26..=51 => (b'a' + (idx - 26)) as char, + _ => (b'0' + (idx - 52)) as char, + } + }) + .collect(); + format!("{}{}", API_KEY_PREFIX, key) + } + + /// Create a new API key for a bot + pub async fn create_api_key( + pool: &PgPool, + name: String, + created_by: i32, + ) -> Result { + let api_key = Self::generate_api_key(); + let key_hash = + hash(&api_key, DEFAULT_COST).map_err(|e| format!("Failed to hash API key: {}", e))?; + + let _ = sqlx::query_as::<_, ApiKey>( + r#" + INSERT INTO ApiKeys (name, key_hash, created_by) + VALUES ($1, $2, $3) + RETURNING + api_key_id, + name, + key_hash, + created_by, + created_at, + last_used_at + "#, + ) + .bind(name) + .bind(key_hash) + .bind(created_by) + .fetch_one(pool) + .await + .map_err(|e| format!("Failed to create API key: {}", e))?; + + Ok(api_key) + } + + /// Validate an API key and return bot member information + pub async fn validate_api_key(pool: &PgPool, api_key: &str) -> Result, String> { + // Check if key has correct prefix + if !api_key.starts_with(API_KEY_PREFIX) { + return Ok(None); + } + + // Fetch all API keys (we need to bcrypt verify each one) + let api_keys = sqlx::query_as::<_, ApiKey>( + r#" + SELECT + api_key_id, + name, + key_hash, + created_by, + created_at, + last_used_at + FROM ApiKeys + "#, + ) + .fetch_all(pool) + .await + .map_err(|e| format!("Failed to fetch API keys: {}", e))?; + + // Find matching API key by verifying hash + for key in api_keys { + if verify(api_key, &key.key_hash).unwrap_or(false) { + // Update last_used_at + let _ = Self::update_last_used(pool, key.api_key_id).await; + + // Create a synthetic Member for the bot + let bot_member = Member { + member_id: -(key.api_key_id), // Negative ID to distinguish from real members + roll_no: None, + name: key.name.clone(), + email: format!("bot-{}@internal.amfoss.in", key.api_key_id), + sex: None, + year: None, + hostel: None, + mac_address: None, + discord_id: None, + group_id: None, + track: None, + github_user: None, + role: Role::Bot, + created_at: key.created_at, + updated_at: key.created_at, + }; + + return Ok(Some(bot_member)); + } + } + + Ok(None) + } + + /// Update the last_used_at timestamp for an API key + async fn update_last_used(pool: &PgPool, api_key_id: i32) -> Result<(), String> { + let now = chrono::Utc::now().with_timezone(&Kolkata); + + sqlx::query( + r#" + UPDATE ApiKeys + SET last_used_at = $1 + WHERE api_key_id = $2 + "#, + ) + .bind(now) + .bind(api_key_id) + .execute(pool) + .await + .map_err(|e| format!("Failed to update last_used_at: {}", e))?; + + Ok(()) + } + + /// Delete an API key + pub async fn delete_api_key(pool: &PgPool, api_key_id: i32) -> Result<(), String> { + sqlx::query( + r#" + DELETE FROM ApiKeys + WHERE api_key_id = $1 + "#, + ) + .bind(api_key_id) + .execute(pool) + .await + .map_err(|e| format!("Failed to delete API key: {}", e))?; + + Ok(()) + } +} From afe56bd537feb3f0cff1b92eae01c85825db75da Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Mon, 17 Nov 2025 22:56:06 +0530 Subject: [PATCH 05/21] feat(auth): implement GitHub OAuth integration --- src/auth/oauth.rs | 203 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 src/auth/oauth.rs diff --git a/src/auth/oauth.rs b/src/auth/oauth.rs new file mode 100644 index 0000000..db86d01 --- /dev/null +++ b/src/auth/oauth.rs @@ -0,0 +1,203 @@ +use crate::models::auth::GitHubUser; +use oauth2::{ + basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, + RedirectUrl, Scope, TokenResponse, TokenUrl, +}; +use reqwest; +use serde::{Deserialize, Serialize}; +use std::env; + +#[derive(Debug, Clone)] +pub struct GitHubOAuthConfig { + pub client_id: String, + pub client_secret: String, + pub redirect_url: String, + pub org_name: String, +} + +impl GitHubOAuthConfig { + pub fn from_env() -> Result { + Ok(Self { + client_id: env::var("GITHUB_CLIENT_ID") + .map_err(|_| "GITHUB_CLIENT_ID not set".to_string())?, + client_secret: env::var("GITHUB_CLIENT_SECRET") + .map_err(|_| "GITHUB_CLIENT_SECRET not set".to_string())?, + redirect_url: env::var("GITHUB_REDIRECT_URL") + .map_err(|_| "GITHUB_REDIRECT_URL not set".to_string())?, + org_name: env::var("GITHUB_ORG_NAME") + .unwrap_or_else(|_| "amfoss".to_string()), + }) + } + + pub fn create_client(&self) -> Result { + let auth_url = AuthUrl::new("https://github.com/login/oauth/authorize".to_string()) + .map_err(|e| format!("Invalid auth URL: {}", e))?; + let token_url = TokenUrl::new("https://github.com/login/oauth/access_token".to_string()) + .map_err(|e| format!("Invalid token URL: {}", e))?; + + let client = BasicClient::new( + ClientId::new(self.client_id.clone()), + Some(ClientSecret::new(self.client_secret.clone())), + auth_url, + Some(token_url), + ) + .set_redirect_uri( + RedirectUrl::new(self.redirect_url.clone()) + .map_err(|e| format!("Invalid redirect URL: {}", e))?, + ); + + Ok(client) + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct GitHubUserResponse { + id: i64, + login: String, + name: Option, + email: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct GitHubEmailResponse { + email: String, + primary: bool, + verified: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +struct GitHubOrgResponse { + login: String, +} + +pub struct GitHubOAuthService { + config: GitHubOAuthConfig, + client: BasicClient, +} + +impl GitHubOAuthService { + pub fn new() -> Result { + let config = GitHubOAuthConfig::from_env()?; + let client = config.create_client()?; + Ok(Self { config, client }) + } + + /// Generate authorization URL for OAuth flow + pub fn get_authorization_url(&self) -> (String, CsrfToken) { + let (auth_url, csrf_token) = self + .client + .authorize_url(CsrfToken::new_random) + .add_scope(Scope::new("read:user".to_string())) + .add_scope(Scope::new("user:email".to_string())) + .add_scope(Scope::new("read:org".to_string())) + .url(); + + (auth_url.to_string(), csrf_token) + } + + /// Exchange authorization code for access token + pub async fn exchange_code(&self, code: String) -> Result { + let token_result = self + .client + .exchange_code(AuthorizationCode::new(code)) + .request_async(oauth2::reqwest::async_http_client) + .await + .map_err(|e| format!("Failed to exchange code: {}", e))?; + + Ok(token_result.access_token().secret().clone()) + } + + /// Fetch GitHub user information + pub async fn get_user_info(&self, access_token: &str) -> Result { + let client = reqwest::Client::new(); + + // Fetch user profile + let user_response: GitHubUserResponse = client + .get("https://api.github.com/user") + .header("Authorization", format!("Bearer {}", access_token)) + .header("User-Agent", "Root-Backend") + .send() + .await + .map_err(|e| format!("Failed to fetch user info: {}", e))? + .json() + .await + .map_err(|e| format!("Failed to parse user info: {}", e))?; + + // Get email (fetch from /user/emails if not in profile) + let email = if let Some(email) = user_response.email { + email + } else { + let emails: Vec = client + .get("https://api.github.com/user/emails") + .header("Authorization", format!("Bearer {}", access_token)) + .header("User-Agent", "Root-Backend") + .send() + .await + .map_err(|e| format!("Failed to fetch user emails: {}", e))? + .json() + .await + .map_err(|e| format!("Failed to parse user emails: {}", e))?; + + emails + .into_iter() + .find(|e| e.primary && e.verified) + .ok_or("No verified primary email found".to_string())? + .email + }; + + Ok(GitHubUser { + github_id: user_response.id, + github_username: user_response.login, + name: user_response.name.unwrap_or_else(|| "Unknown".to_string()), + email, + }) + } + + /// Check if user is member of specified GitHub organization + pub async fn verify_org_membership( + &self, + access_token: &str, + username: &str, + ) -> Result { + let client = reqwest::Client::new(); + + let url = format!( + "https://api.github.com/orgs/{}/members/{}", + self.config.org_name, username + ); + + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", access_token)) + .header("User-Agent", "Root-Backend") + .send() + .await + .map_err(|e| format!("Failed to check org membership: {}", e))?; + + // GitHub returns 204 if member, 404 if not, 302 if needs authentication + Ok(response.status().as_u16() == 204) + } + + /// Complete OAuth flow: exchange code, get user info, verify org membership + pub async fn complete_oauth_flow(&self, code: String) -> Result { + // Exchange code for access token + let access_token = self.exchange_code(code).await?; + + // Get user information + let user_info = self.get_user_info(&access_token).await?; + + // Verify organization membership + let is_member = self + .verify_org_membership(&access_token, &user_info.github_username) + .await?; + + if !is_member { + return Err(format!( + "User is not a member of the {} organization", + self.config.org_name + )); + } + + Ok(user_info) + } +} From e345b23e6f749fa91bc83df4b65648c96347da5f Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Mon, 17 Nov 2025 22:56:43 +0530 Subject: [PATCH 06/21] feat(auth): add authentication middleware and guards --- src/auth/guards.rs | 58 ++++++++++++++++++++++++++++++++++++++++++ src/auth/middleware.rs | 53 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 src/auth/guards.rs create mode 100644 src/auth/middleware.rs diff --git a/src/auth/guards.rs b/src/auth/guards.rs new file mode 100644 index 0000000..c45be0d --- /dev/null +++ b/src/auth/guards.rs @@ -0,0 +1,58 @@ +use crate::auth::AuthContext; +use async_graphql::{Context, Error, Guard, Result}; + +/// Guard that requires any authentication +pub struct AuthGuard; + +impl Guard for AuthGuard { + async fn check(&self, ctx: &Context<'_>) -> Result<()> { + tracing::info!("{:?}", ctx.data::()); + let auth = ctx.data::().map_err(|_| { + Error::new("Authentication context not found. This is an internal server error.") + })?; + + if auth.is_authenticated() { + Ok(()) + } else { + Err(Error::new( + "Authentication required to access this resource.", + )) + } + } +} + +/// Guard that requires admin role +pub struct AdminGuard; + +impl Guard for AdminGuard { + async fn check(&self, ctx: &Context<'_>) -> Result<()> { + let auth = ctx.data::().map_err(|_| { + Error::new("Authentication context not found. This is an internal server error.") + })?; + + if auth.is_admin() { + Ok(()) + } else { + Err(Error::new("Admin privileges required for this operation")) + } + } +} + +/// Guard that requires either admin or bot role +pub struct AdminOrBotGuard; + +impl Guard for AdminOrBotGuard { + async fn check(&self, ctx: &Context<'_>) -> Result<()> { + let auth = ctx.data::().map_err(|_| { + Error::new("Authentication context not found. This is an internal server error.") + })?; + + if auth.is_bot() || auth.is_admin() { + Ok(()) + } else { + Err(Error::new( + "Admin or Bot privileges required for this operation", + )) + } + } +} diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs new file mode 100644 index 0000000..62eef52 --- /dev/null +++ b/src/auth/middleware.rs @@ -0,0 +1,53 @@ +use crate::auth::api_key::ApiKeyService; +use crate::auth::session::SessionService; +use crate::auth::AuthContext; +use axum::{ + extract::Request, + http::{header::AUTHORIZATION, StatusCode}, + middleware::Next, + response::Response, +}; +use sqlx::PgPool; +use std::sync::Arc; + +/// Authentication middleware that extracts member from session token or API key +pub async fn auth_middleware( + pool: Arc, + mut request: Request, + next: Next, +) -> Result { + let auth_header = request + .headers() + .get(AUTHORIZATION) + .and_then(|h| h.to_str().ok()); + + let member = if let Some(auth_value) = auth_header { + // Extract the Bearer token + if let Some(token) = auth_value.strip_prefix("Bearer ").or(Some(auth_value)) { + // Try session token first + let session_member = SessionService::validate_session(&pool, token) + .await + .ok() + .flatten(); + + if session_member.is_some() { + session_member + } else { + // If session returned None, try API key + ApiKeyService::validate_api_key(&pool, token) + .await + .ok() + .flatten() + } + } else { + None + } + } else { + None + }; + + // Inject auth context into request extensions + request.extensions_mut().insert(AuthContext::new(member)); + + Ok(next.run(request).await) +} From ee516c271420df4d9e1899979ba53a914651aee0 Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Mon, 17 Nov 2025 22:58:52 +0530 Subject: [PATCH 07/21] feat(auth): add authentication mutations --- src/graphql/mod.rs | 4 +- src/graphql/mutations/auth_mutations.rs | 134 ++++++++++++++++++++++++ src/graphql/mutations/mod.rs | 2 + 3 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 src/graphql/mutations/auth_mutations.rs diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index a3e2615..ee062d5 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -1,5 +1,5 @@ use async_graphql::MergedObject; -use mutations::{AttendanceMutations, MemberMutations, StatusMutations}; +use mutations::{AttendanceMutations, AuthMutations, MemberMutations, StatusMutations}; use queries::MemberQueries; pub mod mutations; @@ -9,4 +9,4 @@ pub mod queries; pub struct Query(MemberQueries); #[derive(MergedObject, Default)] -pub struct Mutation(MemberMutations, AttendanceMutations, StatusMutations); +pub struct Mutation(MemberMutations, AttendanceMutations, StatusMutations, AuthMutations); diff --git a/src/graphql/mutations/auth_mutations.rs b/src/graphql/mutations/auth_mutations.rs new file mode 100644 index 0000000..710b712 --- /dev/null +++ b/src/graphql/mutations/auth_mutations.rs @@ -0,0 +1,134 @@ +use crate::auth::api_key::ApiKeyService; +use crate::auth::guards::AdminGuard; +use crate::auth::oauth::GitHubOAuthService; +use crate::auth::session::SessionService; +use crate::auth::AuthContext; +use crate::models::auth::{ApiKeyResponse, AuthResponse, GitHubUser, Role}; +use crate::models::member::Member; +use async_graphql::{Context, Object, Result}; +use chrono_tz::Asia::Kolkata; +use sqlx::PgPool; +use std::sync::Arc; + +#[derive(Default)] +pub struct AuthMutations; + +#[Object] +impl AuthMutations { + /// Complete GitHub OAuth flow and create session (for registration or login) + #[graphql(name = "githubOAuthCallback")] + async fn github_oauth_callback(&self, ctx: &Context<'_>, code: String) -> Result { + let pool = ctx.data::>().expect("Pool must be in context."); + + let oauth_service = GitHubOAuthService::new() + .map_err(|e| format!("Failed to initialize OAuth service: {}", e))?; + + let github_user = oauth_service + .complete_oauth_flow(code) + .await + .map_err(|e| format!("OAuth flow failed: {}", e))?; + + // Check if member already exists + let existing_member = sqlx::query_as::<_, Member>( + "SELECT member_id, roll_no, name, email, sex, year, hostel, mac_address, discord_id, + group_id, track, github_user, role, created_at, updated_at + FROM Member + WHERE github_user = $1", + ) + .bind(&github_user.github_username) + .fetch_optional(pool.as_ref()) + .await?; + + let member = if let Some(member) = existing_member { + // Member exists - login + member + } else { + // Member doesn't exist - register + Self::register_member(pool.as_ref(), github_user).await? + }; + + // Create session + let session_token = SessionService::create_session(pool.as_ref(), member.member_id) + .await + .map_err(|e| format!("Failed to create session: {}", e))?; + + Ok(AuthResponse { + member, + session_token, + }) + } + + /// Logout - invalidate session + #[graphql(name = "logout")] + async fn logout(&self, ctx: &Context<'_>, session_token: String) -> Result { + let pool = ctx.data::>().expect("Pool must be in context."); + let auth = ctx + .data::() + .expect("AuthContext must be in context."); + + if auth.is_authenticated() { + SessionService::delete_session_by_token(pool.as_ref(), &session_token) + .await + .map_err(|e| format!("Failed to logout: {}", e))?; + Ok(true) + } else { + Ok(false) + } + } + + /// Create a new bot with API key (Admin only) + #[graphql(name = "createBot", guard = "AdminGuard")] + async fn create_bot(&self, ctx: &Context<'_>, name: String) -> Result { + let pool = ctx.data::>().expect("Pool must be in context."); + let auth = ctx + .data::() + .expect("AuthContext must be in context."); + + let admin_member = auth + .user + .as_ref() + .ok_or("Admin member not found in context")?; + + // Create API key + let api_key = ApiKeyService::create_api_key(pool.as_ref(), name, admin_member.member_id) + .await + .map_err(|e| format!("Failed to create bot: {}", e))?; + + Ok(ApiKeyResponse { api_key }) + } + + /// Delete a bot (Admin only) + #[graphql(name = "deleteBot", guard = "AdminGuard")] + async fn delete_bot(&self, ctx: &Context<'_>, api_key_id: i32) -> Result { + let pool = ctx.data::>().expect("Pool must be in context."); + + ApiKeyService::delete_api_key(pool.as_ref(), api_key_id) + .await + .map_err(|e| format!("Failed to delete bot: {}", e))?; + + Ok(true) + } +} + +impl AuthMutations { + async fn register_member(pool: &PgPool, github_user: GitHubUser) -> Result { + let now = chrono::Utc::now().with_timezone(&Kolkata); + + let member = sqlx::query_as::<_, Member>( + "INSERT INTO Member (name, email, github_user, role, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING member_id, roll_no, name, email, sex, year, hostel, mac_address, discord_id, + group_id, track, github_user, role, created_at, updated_at", + ) + .bind(github_user.name) + .bind(github_user.email) + .bind(github_user.github_username) + .bind(Role::Member) + .bind(now) + .bind(now) + .fetch_one(pool) + .await?; + + Ok(member) + } +} diff --git a/src/graphql/mutations/mod.rs b/src/graphql/mutations/mod.rs index 8bfc1c9..6c58cfa 100644 --- a/src/graphql/mutations/mod.rs +++ b/src/graphql/mutations/mod.rs @@ -1,7 +1,9 @@ pub mod attendance_mutations; +pub mod auth_mutations; pub mod member_mutations; pub mod status_mutations; pub use attendance_mutations::AttendanceMutations; +pub use auth_mutations::AuthMutations; pub use member_mutations::MemberMutations; pub use status_mutations::StatusMutations; From 673c9b86ab29ff4f711c8a48460d8ce851506489 Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Mon, 17 Nov 2025 23:03:19 +0530 Subject: [PATCH 08/21] feat(auth): add authentication guards to existing queries and mutations --- src/graphql/mutations/attendance_mutations.rs | 4 ++-- src/graphql/mutations/member_mutations.rs | 10 ++++++---- src/graphql/mutations/status_mutations.rs | 3 +++ src/graphql/queries/member_queries.rs | 3 +++ 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/graphql/mutations/attendance_mutations.rs b/src/graphql/mutations/attendance_mutations.rs index f3aea54..42ada89 100644 --- a/src/graphql/mutations/attendance_mutations.rs +++ b/src/graphql/mutations/attendance_mutations.rs @@ -1,12 +1,12 @@ use std::sync::Arc; use async_graphql::{Context, Object, Result}; -use chrono::Local; use chrono_tz::Asia::Kolkata; use hmac::{Hmac, Mac}; use sha2::Sha256; use sqlx::PgPool; +use crate::auth::guards::AdminOrBotGuard; use crate::models::attendance::{AttendanceRecord, MarkAttendanceInput}; type HmacSha256 = Hmac; @@ -16,7 +16,7 @@ pub struct AttendanceMutations; #[Object] impl AttendanceMutations { - #[graphql(name = "markAttendance")] + #[graphql(name = "markAttendance", guard = "AdminOrBotGuard")] async fn mark_attendance( &self, ctx: &Context<'_>, diff --git a/src/graphql/mutations/member_mutations.rs b/src/graphql/mutations/member_mutations.rs index c6c6ebf..247bf5d 100644 --- a/src/graphql/mutations/member_mutations.rs +++ b/src/graphql/mutations/member_mutations.rs @@ -1,3 +1,5 @@ +use crate::auth::guards::{AdminOrBotGuard, AuthGuard}; +use crate::models::auth::Role; use crate::models::member::{CreateMemberInput, Member, UpdateMemberInput}; use async_graphql::{Context, Object, Result}; use chrono::Local; @@ -10,13 +12,13 @@ pub struct MemberMutations; #[Object] impl MemberMutations { - #[graphql(name = "createMember")] + #[graphql(name = "createMember", guard = "AdminOrBotGuard")] async fn create_member(&self, ctx: &Context<'_>, input: CreateMemberInput) -> Result { let pool = ctx.data::>().expect("Pool must be in context."); let now = Local::now().with_timezone(&Kolkata).date_naive(); let member = sqlx::query_as::<_, Member>( - "INSERT INTO Member (roll_no, name, email, sex, year, hostel, mac_address, discord_id, group_id, track, github_user, created_at) + "INSERT INTO Member (roll_no, name, email, sex, year, hostel, mac_address, discord_id, group_id, track, github_user, role) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *" ) .bind(&input.roll_no) @@ -30,14 +32,14 @@ impl MemberMutations { .bind(input.group_id) .bind(&input.track) .bind(&input.github_user) - .bind(now) + .bind(Role::Member) .fetch_one(pool.as_ref()) .await?; Ok(member) } - #[graphql(name = "updateMember")] + #[graphql(name = "updateMember", guard = "AuthGuard")] async fn update_member(&self, ctx: &Context<'_>, input: UpdateMemberInput) -> Result { let pool = ctx.data::>().expect("Pool must be in context."); diff --git a/src/graphql/mutations/status_mutations.rs b/src/graphql/mutations/status_mutations.rs index efe811c..874e8ba 100644 --- a/src/graphql/mutations/status_mutations.rs +++ b/src/graphql/mutations/status_mutations.rs @@ -3,6 +3,7 @@ use chrono::NaiveDate; use sqlx::PgPool; use std::sync::Arc; +use crate::auth::guards::{AdminGuard, AdminOrBotGuard}; use crate::models::status_update::{CreateStatusBreakInput, StatusBreakRecord, StatusUpdateRecord}; #[derive(Default)] @@ -10,6 +11,7 @@ pub struct StatusMutations; #[Object] impl StatusMutations { + #[graphql(name = "markStatusUpdate", guard = "AdminOrBotGuard")] async fn mark_status_update( &self, ctx: &Context<'_>, @@ -34,6 +36,7 @@ impl StatusMutations { Ok(status) } + #[graphql(name = "createStatusBreak", guard = "AdminGuard")] async fn create_status_break( &self, ctx: &Context<'_>, diff --git a/src/graphql/queries/member_queries.rs b/src/graphql/queries/member_queries.rs index 209f542..98443e0 100644 --- a/src/graphql/queries/member_queries.rs +++ b/src/graphql/queries/member_queries.rs @@ -1,3 +1,4 @@ +use crate::auth::guards::AuthGuard; use crate::models::{attendance::AttendanceRecord, status_update::StatusUpdateRecord}; use async_graphql::{ComplexObject, Context, Object, Result}; use chrono::NaiveDate; @@ -19,6 +20,7 @@ pub struct AttendanceInfo { #[Object] impl MemberQueries { + #[graphql(guard = "AuthGuard")] pub async fn all_members( &self, ctx: &Context<'_>, @@ -47,6 +49,7 @@ impl MemberQueries { Ok(members) } + #[graphql(guard = "AuthGuard")] async fn member( &self, ctx: &Context<'_>, From 871536bc1d1543c15943725dc5f960578e3e9ba2 Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Mon, 17 Nov 2025 23:04:26 +0530 Subject: [PATCH 09/21] feat(router): integrate authentication middleware and oauth routes --- src/main.rs | 8 ++-- src/routes.rs | 106 +++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 98 insertions(+), 16 deletions(-) diff --git a/src/main.rs b/src/main.rs index c69d170..d072c27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ use database_seeder::seed_database; use graphql::{Mutation, Query}; use routes::setup_router; +pub mod auth; pub mod daily_task; pub mod database_seeder; pub mod graphql; @@ -57,12 +58,13 @@ async fn main() { seed_database(&pool).await; } - tokio::task::spawn(async { - run_daily_task_at_midnight(pool).await; + let pool_for_task = pool.clone(); + tokio::task::spawn(async move { + run_daily_task_at_midnight(pool_for_task).await; }); let cors = setup_cors(); - let router = setup_router(schema, cors, config.env == "development"); + let router = setup_router(schema, cors, config.env == "development", pool); info!("Starting Root..."); let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port)) diff --git a/src/routes.rs b/src/routes.rs index f6c6db2..d4a81e5 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,32 +1,57 @@ use async_graphql::{http::GraphiQLSource, EmptySubscription, Schema}; -use async_graphql_axum::GraphQL; +use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; use axum::{ - response::{Html, IntoResponse}, - routing::get, - Router, + extract::{Extension, Query as AxumQuery, State}, + middleware, + response::{Html, IntoResponse, Redirect}, + routing::{get, post}, + Json, Router, }; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::sync::Arc; use tower_http::cors::CorsLayer; +use crate::auth::middleware::auth_middleware; +use crate::auth::oauth::GitHubOAuthService; +use crate::auth::AuthContext; use crate::graphql::{Mutation, Query}; +async fn graphql_handler( + State(schema): State>, + Extension(auth_context): Extension, + req: GraphQLRequest, +) -> GraphQLResponse { + schema + .execute(req.into_inner().data(auth_context)) + .await + .into() +} + pub fn setup_router( schema: Schema, cors: CorsLayer, is_dev: bool, + pool: Arc, ) -> Router { - let router = Router::new() - .route_service("/", GraphQL::new(schema.clone())) - .layer(cors); + let pool_for_middleware = pool.clone(); + + let mut router = Router::new() + .route("/", post(graphql_handler)) + .route("/auth/github", get(github_oauth_init)) + .route("/auth/github/callback", get(github_oauth_callback)); if is_dev { tracing::info!("GraphiQL playground enabled at /graphiql"); - router.route( - "/graphiql", - get(graphiql).post_service(GraphQL::new(schema)), - ) - } else { - router + router = router.route("/graphiql", get(graphiql).post(graphql_handler)); } + + router + .layer(middleware::from_fn(move |req, next| { + auth_middleware(pool_for_middleware.clone(), req, next) + })) + .layer(cors) + .with_state(schema) } async fn graphiql() -> impl IntoResponse { @@ -37,3 +62,58 @@ async fn graphiql() -> impl IntoResponse { .finish(), ) } + +// OAuth handlers + +/// Initiates GitHub OAuth flow +async fn github_oauth_init() -> Result { + let oauth_service = + GitHubOAuthService::new().map_err(|e| format!("Failed to initialize OAuth: {}", e))?; + + let (auth_url, _csrf_token) = oauth_service.get_authorization_url(); + + // In production grade systems, we should be storing the token on the server side and returning + // a session ID to the frontend. When the frontend calls the server for the authorization + // code exchange, it should include the ID [eg: as a cookie] so that the backend can verify + // that the state parameter from the authorization server matches it. This is to prevent CSRF + // attacks. + // + // For now, we'll just redirect + Ok(Redirect::temporary(&auth_url)) +} + +#[derive(Deserialize)] +struct OAuthCallbackQuery { + code: String, + #[allow(dead_code)] + state: Option, +} + +#[derive(Serialize)] +struct OAuthCallbackResponse { + success: bool, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + redirect_url: Option, +} + +/// Sample GitHub OAuth callback (used for testing purposes) +async fn github_oauth_callback( + AxumQuery(query): AxumQuery, +) -> Json { + // In a real implementation, you should: + // 0. Handle this callback in the frontend + // 1. Verify the CSRF token (state parameter) + // 2. Call the githubOAuthCallback GraphQL mutation with the code from the URL parameter + // 3. Store the session token and use it for authentication. + + // For now, we'll return a response that the frontend can handle + Json(OAuthCallbackResponse { + success: true, + message: format!( + "OAuth callback received. Use code '{}' with githubOAuthCallback mutation.", + query.code + ), + redirect_url: Some(format!("/graphql?code={}", query.code)), + }) +} From 51e52cce761616c7db9a4654a47ac2f6a33551b5 Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Mon, 17 Nov 2025 23:07:09 +0530 Subject: [PATCH 10/21] feat: add expired session cleanup to daily tasks --- src/daily_task/mod.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/daily_task/mod.rs b/src/daily_task/mod.rs index 7789d4b..a3b5bbc 100644 --- a/src/daily_task/mod.rs +++ b/src/daily_task/mod.rs @@ -1,3 +1,4 @@ +use crate::auth::session::SessionService; use chrono::NaiveTime; use chrono_tz::Asia::Kolkata; use sqlx::PgPool; @@ -36,7 +37,14 @@ pub async fn run_daily_task_at_midnight(pool: Arc) { /// This function does a number of things, including: /// * Insert new attendance records everyday for [`presense`](https://www.github.com/amfoss/presense) to update them later in the day. +/// * Delete expired user sessions. async fn execute_daily_task(pool: Arc) { + if let Ok(rows_deleted) = SessionService::cleanup_expired_sessions(&pool).await { + if rows_deleted > 0 { + tracing::info!("Cleaned up {:?} expired sessions", rows_deleted); + } + } + // Members is queried outside of each function to avoid repetition let members = sqlx::query_as::<_, Member>("SELECT * FROM Member") .fetch_all(&*pool) From 396d20fb202e7919f9175c37b25c7539e8223699 Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Mon, 17 Nov 2025 23:07:52 +0530 Subject: [PATCH 11/21] docs: add documentation for the authentication system --- docs/auth.md | 397 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 docs/auth.md diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..f9a1a2e --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,397 @@ +# Authentication System Documentation + +This document describes the authentication and authorization system for Root backend. + +| Section | Description | +|--------|-------------| +| [Overview](#overview) | Summary of supported authentication methods | +| [Roles and Permissions](#roles-and-permissions) | Roles (Admin, Member, Bot) and protected mutations | +| [Setup](#setup) | How to configure OAuth, env vars, and database migration | +| [OAuth Flow](#usage) | Full OAuth login flow and response structure | +| [Bot Management](#bot-management) | Bot creation and API key handling | +| [Role Based Access Control (RBAC)](#permission-checking-in-code) | GraphQL guards and access control | +| [Troubleshooting](#troubleshooting) | Common issues and how to fix them | +| [API Reference](#api-reference) | GraphQL mutations and their inputs/outputs | +| [Example](#example) | Complete OAuth authentication flow example | +| [Architecture Notes](#architecture-notes) | Middleware flow, bot member structure, key management | + + + +## Overview + +The authentication system supports three types of authentication: +1. **GitHub OAuth** - For human members who are part of the amfoss GitHub organization +2. **API Keys** - For headless bots and automated services +3. **Session Tokens** - For maintaining logged-in state after OAuth + +## Roles and Permissions + +### Roles + +- **Admin**: Full system access, can create/manage bots, access all mutations and queries +- **Member**: Standard user permissions, authenticated via GitHub OAuth, limited access +- **Bot**: Service accounts with API key authentication, can access protected mutations + +Note that unauthenticated users have essentially no access to the system. + +### Protected Mutations + +The following mutations require Admin or Bot role: +- `createMember` +- `markAttendance` +- `markStatusUpdate` +- `createStatusBreak` + +Regular Members cannot access these mutations. + +## Setup + +### 1. GitHub OAuth Application + +Create a GitHub OAuth application at: https://github.com/settings/developers + +**Settings:** +- Application name: Root Backend (or your choice) +- Homepage URL: `http://localhost:3000` (or your domain) +- Authorization callback URL: `http://localhost:5000/auth/github/callback` + +After creating, note down the **Client ID** and **Client Secret**. + +### 2. Environment Variables + +Add the following to your `.env` file: + +```bash +# GitHub OAuth credentials +GITHUB_CLIENT_ID=your_client_id_here +GITHUB_CLIENT_SECRET=your_client_secret_here +GITHUB_REDIRECT_URL=http://localhost:5000/auth/github/callback +GITHUB_ORG_NAME=amfoss # Organization that users must be part of +``` + +### 3. Database Migration + +Run the authentication system migration: + +```bash +sqlx migrate run +``` + +This creates the following tables: +- `Member` - Stores authenticated members with their details +- `Sessions` - Stores session tokens +- `ApiKeys` - Stores hashed API keys for bots + +### 4. Making Your First Admin + +After setting up the system, you need to create the first admin user manually: + +1. Register via GitHub OAuth (see below) +2. Connect to the database: + ```bash + psql $ROOT_DB_URL + ``` +3. Promote your user to admin: + ```sql + UPDATE Member SET role = 'Admin' WHERE email = 'your@email.com'; + ``` +4. Verify: + ```sql + SELECT member_id, name, email, role FROM Member WHERE email = 'your@email.com'; + ``` + +Now you can create bots and manage the system! +## Usage + +### Member Registration & Login (GitHub OAuth) + + +1. User visits: `http://localhost:5000/auth/github` +2. Gets redirected to GitHub for authorization +3. After authorization, GitHub redirects to the frontend at: `/auth/github/callback?code=...` +4. Frontend receives the OAuth code +5. Frontend calls GraphQL mutation with the code and the backend returns the session token for the user: + +```graphql +mutation { + githubOAuthCallback(code: "oauth_code_here") { + member { + memberId + name + email + role + githubUser + } + sessionToken + } +} +``` + +**Response:** +```json +{ + "data": { + "githubOAuthCallback": { + "member": { + "memberId": 1, + "githubUser": "johndoe", + "name": "John Doe", + "email": "john@example.com", + "role": "Member" + }, + "sessionToken": "abc123...xyz789" + } + } +} +``` + +6. Frontend stores the session token (in localStorage, cookie, etc.) +7. Frontend includes token in subsequent requests via Authorization header + + +**Important:** +- First time users are automatically registered +- Users must be members of the amfoss GitHub organization +- Non-members receive an error + +### Making Authenticated Requests + +Include the session token in the Authorization header: + +``` +Authorization: Bearer +``` + +Example with curl: +```bash +curl -X POST http://localhost:3000/ \ + -H "Authorization: Bearer abc123...xyz789" \ + -H "Content-Type: application/json" \ + -d '{ + "query": "{ member(memberId: 1) { name email } }" + }' +``` + +### Logout + +```graphql +mutation { + logout(sessionToken: "your_session_token_here") +} +``` + +This invalidates the specified session for the current user. + +**Returns:** `true` if successful, `false` if not authenticated + +## Bot Management +### Creating Bots (Admin Only) + +Admins can create bot accounts with API keys: + +```graphql +mutation { + createBot(name: "Presence Bot") +} +``` + +**Response:** +```json +{ + "data": { + "createBot": { + "apiKey": "root_wnTK5uRq8FECFSvSC8OVZ8h0SSJefTMlvGWJmsS4" + } + } +} +``` + +**⚠️ Important:** The API key is only returned ONCE. Store it securely! + +### Using API Keys (Bots) + +Bots use API keys instead of session tokens. Include the API key in the `Authorization` header in the same format as before. + +### Deleting Bots (Admin Only) + +```graphql +mutation { + deleteBot(apiKeyId: 1) +} +``` + +**Returns:** `true` if successful + + +## Permission Checking in Code + +### In GraphQL Resolvers + +Protected mutations use guards: + +```rust +use crate::auth::guards::{AdminGuard, AdminOrBotGuard, AuthGuard}; + +#[Object] +impl SomeMutations { + #[graphql(name = "protectedMutation", guard = "AdminOrBotGuard")] + async fn protected_mutation(&self, ctx: &Context<'_>) -> Result { + // Only admins and bots can reach here + Ok("Success".to_string()) + } + + #[graphql(name = "memberOnlyMutation", guard = "AuthGuard")] + async fn member_only_mutation(&self, ctx: &Context<'_>) -> Result { + // Any authenticated user can reach here + Ok("Success".to_string()) + } + + #[graphql(name = "adminOnlyMutation", guard = "AdminGuard")] + async fn admin_only_mutation(&self, ctx: &Context<'_>) -> Result { + // Only admins can reach here + Ok("Success".to_string()) + } +} +``` + +### Available Guards + +- `AuthGuard` - Requires any authenticated user (Member, Admin, or Bot) +- `AdminGuard` - Requires Admin role +- `AdminOrBotGuard` - Requires Admin or Bot role + +## Troubleshooting + +### "Authentication context not found" Error + +The auth middleware is not properly configured. Ensure: + +- That the `Authorization:` header is present in the HTTP Request. + +### "User is not a member of the amfoss organization" + +The GitHub account is not part of the specified organization. Either: +1. Add the user to the organization +2. Change `GITHUB_ORG_NAME` in .env (for testing) + +## API Reference + +### GraphQL Mutations + +#### `githubOAuthCallback(code: String!): AuthResponse!` + +Complete OAuth flow and create session. + +**Input:** +- `code`: OAuth authorization code from GitHub + +**Returns:** +- `member`: Member information including memberId, name, email, role, githubUser +- `sessionToken`: Session token for subsequent requests + +--- + +#### `logout(sessionToken: String!): Boolean!` + +Invalidate the specified session for current user. + +**Input:** +- `sessionToken`: The session token to invalidate + +**Returns:** `true` if successful, `false` if not authenticated + +--- + +#### `createBot(name: String!): String!` 🔒 Admin only + +Create a new bot with API key. + +**Input:** +- `name`: Bot name/description + +**Returns:** The API key string (only shown once!) + +--- + +#### `deleteBot(apiKeyId: Int!): Boolean!` 🔒 Admin only + +Delete a bot and revoke its API key. + +**Input:** +- `apiKeyId`: ID of the API key to delete + +**Returns:** `true` if successful + +## Example + +### Complete Member Authentication Flow + +```javascript +// 1. Redirect to GitHub OAuth +window.location.href = 'http://localhost:3000/auth/github'; + +// 2. After callback, extract code from URL +const urlParams = new URLSearchParams(window.location.search); +const code = urlParams.get('code'); + +// 3. Call GraphQL mutation +const response = await fetch('http://localhost:3000/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + mutation($code: String!) { + githubOAuthCallback(code: $code) { + member { memberId name email role } + sessionToken + } + } + `, + variables: { code } + }) +}); + +const { data } = await response.json(); +const sessionToken = data.githubOAuthCallback.sessionToken; + +// 4. Store token +localStorage.setItem('sessionToken', sessionToken); + +// 5. Use token in subsequent requests +fetch('http://localhost:3000/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${sessionToken}` + }, + body: JSON.stringify({ + query: '{ member(memberId: 1) { name } }' + }) +}); +``` +## Architecture Notes + +### Authentication Flow + +1. **Request arrives** → `auth_middleware` extracts Authorization header +2. **Token validation** → Tries session token first, then API key +3. **Member lookup** → Returns associated Member or None +4. **Context injection** → AuthContext with Member is added to request extensions +5. **GraphQL execution** → Guards check AuthContext for permissions +6. **Response** → Returns data or permission error + +### Bot Members + +Bots are represented as synthetic Member objects with: +- Negative member_id (to distinguish from real members) +- Role set to Bot +- Email format: `bot-{api_key_id}@internal.amfoss.in` +- Name from API key name + + +### API Key Management + +- API keys are prefixed with `root_` +- Keys are hashed with bcrypt (cost factor 12) before storage +- Last used timestamp is updated on each validation +- Keys can only be deleted by admins From 0f7524e5ddb80e25897667fa201f9383f0d14a9f Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Mon, 17 Nov 2025 23:07:28 +0530 Subject: [PATCH 12/21] refactor: use UTC timezone consistently --- src/graphql/mutations/attendance_mutations.rs | 3 ++- src/graphql/mutations/member_mutations.rs | 3 --- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/graphql/mutations/attendance_mutations.rs b/src/graphql/mutations/attendance_mutations.rs index 42ada89..953b032 100644 --- a/src/graphql/mutations/attendance_mutations.rs +++ b/src/graphql/mutations/attendance_mutations.rs @@ -42,7 +42,8 @@ impl AttendanceMutations { return Err(async_graphql::Error::new("HMAC verification failed")); } - let now = Local::now().with_timezone(&Kolkata).time(); + let now = chrono::Utc::now().with_timezone(&Kolkata); + let attendance = sqlx::query_as::<_, AttendanceRecord>( "UPDATE Attendance SET time_in = CASE WHEN time_in IS NULL THEN $1 diff --git a/src/graphql/mutations/member_mutations.rs b/src/graphql/mutations/member_mutations.rs index 247bf5d..010ef3e 100644 --- a/src/graphql/mutations/member_mutations.rs +++ b/src/graphql/mutations/member_mutations.rs @@ -2,8 +2,6 @@ use crate::auth::guards::{AdminOrBotGuard, AuthGuard}; use crate::models::auth::Role; use crate::models::member::{CreateMemberInput, Member, UpdateMemberInput}; use async_graphql::{Context, Object, Result}; -use chrono::Local; -use chrono_tz::Asia::Kolkata; use sqlx::PgPool; use std::sync::Arc; @@ -15,7 +13,6 @@ impl MemberMutations { #[graphql(name = "createMember", guard = "AdminOrBotGuard")] async fn create_member(&self, ctx: &Context<'_>, input: CreateMemberInput) -> Result { let pool = ctx.data::>().expect("Pool must be in context."); - let now = Local::now().with_timezone(&Kolkata).date_naive(); let member = sqlx::query_as::<_, Member>( "INSERT INTO Member (roll_no, name, email, sex, year, hostel, mac_address, discord_id, group_id, track, github_user, role) From 1926e2ff6ba0b4d0a9b31c5eb49cabf5ef1eed1d Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Mon, 17 Nov 2025 23:30:31 +0530 Subject: [PATCH 13/21] fix: remove accidental change to gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2da9a40..c12db7a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,3 @@ Secrets*.toml backups/ .env *.log -.aider* From e7c194ddef04f398b315201328808728b4ea9cef Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Mon, 17 Nov 2025 23:38:27 +0530 Subject: [PATCH 14/21] fix: clippy and rustfmt errors --- src/auth/oauth.rs | 12 +++--------- src/graphql/mod.rs | 7 ++++++- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/auth/oauth.rs b/src/auth/oauth.rs index db86d01..ebdc940 100644 --- a/src/auth/oauth.rs +++ b/src/auth/oauth.rs @@ -1,7 +1,7 @@ use crate::models::auth::GitHubUser; use oauth2::{ - basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, - RedirectUrl, Scope, TokenResponse, TokenUrl, + basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, RedirectUrl, + Scope, TokenResponse, TokenUrl, }; use reqwest; use serde::{Deserialize, Serialize}; @@ -24,8 +24,7 @@ impl GitHubOAuthConfig { .map_err(|_| "GITHUB_CLIENT_SECRET not set".to_string())?, redirect_url: env::var("GITHUB_REDIRECT_URL") .map_err(|_| "GITHUB_REDIRECT_URL not set".to_string())?, - org_name: env::var("GITHUB_ORG_NAME") - .unwrap_or_else(|_| "amfoss".to_string()), + org_name: env::var("GITHUB_ORG_NAME").unwrap_or_else(|_| "amfoss".to_string()), }) } @@ -65,11 +64,6 @@ struct GitHubEmailResponse { verified: bool, } -#[derive(Debug, Serialize, Deserialize)] -struct GitHubOrgResponse { - login: String, -} - pub struct GitHubOAuthService { config: GitHubOAuthConfig, client: BasicClient, diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index ee062d5..e6475df 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -9,4 +9,9 @@ pub mod queries; pub struct Query(MemberQueries); #[derive(MergedObject, Default)] -pub struct Mutation(MemberMutations, AttendanceMutations, StatusMutations, AuthMutations); +pub struct Mutation( + MemberMutations, + AttendanceMutations, + StatusMutations, + AuthMutations, +); From 91252bac0f13735d093a412287427c46cf932fbf Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Mon, 17 Nov 2025 23:43:39 +0530 Subject: [PATCH 15/21] chore: cleanup comments --- src/auth/api_key.rs | 8 -------- src/auth/guards.rs | 4 ---- src/auth/middleware.rs | 3 --- src/auth/mod.rs | 1 - src/auth/oauth.rs | 11 +---------- src/auth/session.rs | 6 ------ 6 files changed, 1 insertion(+), 32 deletions(-) diff --git a/src/auth/api_key.rs b/src/auth/api_key.rs index 7360f64..31847d2 100644 --- a/src/auth/api_key.rs +++ b/src/auth/api_key.rs @@ -11,7 +11,6 @@ const API_KEY_PREFIX: &str = "root_"; pub struct ApiKeyService; impl ApiKeyService { - /// Generate a random API key with prefix fn generate_api_key() -> String { let mut rng = rand::thread_rng(); let key: String = (0..API_KEY_LENGTH) @@ -27,7 +26,6 @@ impl ApiKeyService { format!("{}{}", API_KEY_PREFIX, key) } - /// Create a new API key for a bot pub async fn create_api_key( pool: &PgPool, name: String, @@ -60,9 +58,7 @@ impl ApiKeyService { Ok(api_key) } - /// Validate an API key and return bot member information pub async fn validate_api_key(pool: &PgPool, api_key: &str) -> Result, String> { - // Check if key has correct prefix if !api_key.starts_with(API_KEY_PREFIX) { return Ok(None); } @@ -84,10 +80,8 @@ impl ApiKeyService { .await .map_err(|e| format!("Failed to fetch API keys: {}", e))?; - // Find matching API key by verifying hash for key in api_keys { if verify(api_key, &key.key_hash).unwrap_or(false) { - // Update last_used_at let _ = Self::update_last_used(pool, key.api_key_id).await; // Create a synthetic Member for the bot @@ -116,7 +110,6 @@ impl ApiKeyService { Ok(None) } - /// Update the last_used_at timestamp for an API key async fn update_last_used(pool: &PgPool, api_key_id: i32) -> Result<(), String> { let now = chrono::Utc::now().with_timezone(&Kolkata); @@ -136,7 +129,6 @@ impl ApiKeyService { Ok(()) } - /// Delete an API key pub async fn delete_api_key(pool: &PgPool, api_key_id: i32) -> Result<(), String> { sqlx::query( r#" diff --git a/src/auth/guards.rs b/src/auth/guards.rs index c45be0d..0dffb95 100644 --- a/src/auth/guards.rs +++ b/src/auth/guards.rs @@ -1,12 +1,10 @@ use crate::auth::AuthContext; use async_graphql::{Context, Error, Guard, Result}; -/// Guard that requires any authentication pub struct AuthGuard; impl Guard for AuthGuard { async fn check(&self, ctx: &Context<'_>) -> Result<()> { - tracing::info!("{:?}", ctx.data::()); let auth = ctx.data::().map_err(|_| { Error::new("Authentication context not found. This is an internal server error.") })?; @@ -21,7 +19,6 @@ impl Guard for AuthGuard { } } -/// Guard that requires admin role pub struct AdminGuard; impl Guard for AdminGuard { @@ -38,7 +35,6 @@ impl Guard for AdminGuard { } } -/// Guard that requires either admin or bot role pub struct AdminOrBotGuard; impl Guard for AdminOrBotGuard { diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs index 62eef52..567d298 100644 --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -10,7 +10,6 @@ use axum::{ use sqlx::PgPool; use std::sync::Arc; -/// Authentication middleware that extracts member from session token or API key pub async fn auth_middleware( pool: Arc, mut request: Request, @@ -22,9 +21,7 @@ pub async fn auth_middleware( .and_then(|h| h.to_str().ok()); let member = if let Some(auth_value) = auth_header { - // Extract the Bearer token if let Some(token) = auth_value.strip_prefix("Bearer ").or(Some(auth_value)) { - // Try session token first let session_member = SessionService::validate_session(&pool, token) .await .ok() diff --git a/src/auth/mod.rs b/src/auth/mod.rs index c38626b..6cb3c72 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -7,7 +7,6 @@ pub mod session; use crate::models::auth::Role; use crate::models::member::Member; -// Context for authenticated requests #[derive(Clone, Debug)] pub struct AuthContext { pub user: Option, diff --git a/src/auth/oauth.rs b/src/auth/oauth.rs index ebdc940..ecd3c8d 100644 --- a/src/auth/oauth.rs +++ b/src/auth/oauth.rs @@ -76,7 +76,6 @@ impl GitHubOAuthService { Ok(Self { config, client }) } - /// Generate authorization URL for OAuth flow pub fn get_authorization_url(&self) -> (String, CsrfToken) { let (auth_url, csrf_token) = self .client @@ -89,7 +88,6 @@ impl GitHubOAuthService { (auth_url.to_string(), csrf_token) } - /// Exchange authorization code for access token pub async fn exchange_code(&self, code: String) -> Result { let token_result = self .client @@ -101,11 +99,9 @@ impl GitHubOAuthService { Ok(token_result.access_token().secret().clone()) } - /// Fetch GitHub user information pub async fn get_user_info(&self, access_token: &str) -> Result { let client = reqwest::Client::new(); - // Fetch user profile let user_response: GitHubUserResponse = client .get("https://api.github.com/user") .header("Authorization", format!("Bearer {}", access_token)) @@ -117,7 +113,7 @@ impl GitHubOAuthService { .await .map_err(|e| format!("Failed to parse user info: {}", e))?; - // Get email (fetch from /user/emails if not in profile) + // Fetch from /user/emails if not in profile let email = if let Some(email) = user_response.email { email } else { @@ -147,7 +143,6 @@ impl GitHubOAuthService { }) } - /// Check if user is member of specified GitHub organization pub async fn verify_org_membership( &self, access_token: &str, @@ -172,15 +167,11 @@ impl GitHubOAuthService { Ok(response.status().as_u16() == 204) } - /// Complete OAuth flow: exchange code, get user info, verify org membership pub async fn complete_oauth_flow(&self, code: String) -> Result { - // Exchange code for access token let access_token = self.exchange_code(code).await?; - // Get user information let user_info = self.get_user_info(&access_token).await?; - // Verify organization membership let is_member = self .verify_org_membership(&access_token, &user_info.github_username) .await?; diff --git a/src/auth/session.rs b/src/auth/session.rs index cbfe201..43000c5 100644 --- a/src/auth/session.rs +++ b/src/auth/session.rs @@ -11,7 +11,6 @@ const TOKEN_LENGTH: usize = 64; pub struct SessionService; impl SessionService { - /// Generate a random session token fn generate_token() -> String { let mut rng = rand::thread_rng(); let token: String = (0..TOKEN_LENGTH) @@ -27,14 +26,12 @@ impl SessionService { token } - /// Hash a token using SHA-256 fn hash_token(token: &str) -> String { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); format!("{:x}", hasher.finalize()) } - /// Create a new session for a member pub async fn create_session(pool: &PgPool, member_id: i32) -> Result { let token = Self::generate_token(); let token_hash = Self::hash_token(&token); @@ -56,7 +53,6 @@ impl SessionService { Ok(token) } - /// Validate a session token and return the associated member pub async fn validate_session(pool: &PgPool, token: &str) -> Result, String> { let token_hash = Self::hash_token(token); let now = chrono::Utc::now().with_timezone(&Kolkata); @@ -77,7 +73,6 @@ impl SessionService { Ok(result) } - /// Delete a session by its token pub async fn delete_session_by_token(pool: &PgPool, token: &str) -> Result<(), String> { let token_hash = Self::hash_token(token); @@ -95,7 +90,6 @@ impl SessionService { Ok(()) } - /// Clean up expired sessions (should be run periodically) pub async fn cleanup_expired_sessions(pool: &PgPool) -> Result { let now = chrono::Utc::now().with_timezone(&Kolkata); From d8a260b5517a9c0f60aab9af63d6d2b8a2ff0d32 Mon Sep 17 00:00:00 2001 From: "Hridesh MG (aider)" Date: Thu, 20 Nov 2025 22:13:21 +0530 Subject: [PATCH 16/21] feat: migrate GitHub OAuth to backend-driven flow with session cookies --- .env.sample | 3 +- Cargo.lock | 38 ++++++++++- Cargo.toml | 1 + src/auth/auth_service.rs | 62 ++++++++++++++++++ src/auth/middleware.rs | 32 ++++----- src/auth/mod.rs | 1 + src/graphql/mutations/auth_mutations.rs | 71 +------------------- src/routes.rs | 87 ++++++++++++++----------- 8 files changed, 166 insertions(+), 129 deletions(-) create mode 100644 src/auth/auth_service.rs diff --git a/.env.sample b/.env.sample index 819320b..8bc8948 100644 --- a/.env.sample +++ b/.env.sample @@ -13,7 +13,8 @@ ROOT_PORT=3000 # GitHub OAuth for authentication GITHUB_CLIENT_ID=your_github_oauth_app_client_id GITHUB_CLIENT_SECRET=your_github_oauth_app_client_secret -GITHUB_REDIRECT_URL=http://localhost:5000/auth/github/callback # Call back to frontend +GITHUB_REDIRECT_URL=http://localhost:5000/auth/github/callback #Oauth Callback +FRONTEND_URL=http://localhost:3000/dashboard #Redirect after OAuth GITHUB_ORG_NAME=amfoss # Seed toggle diff --git a/Cargo.lock b/Cargo.lock index 415569e..320d183 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -211,9 +211,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "axum" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" +checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" dependencies = [ "axum-core", "base64 0.22.1", @@ -264,6 +264,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbfe9f610fe4e99cf0cfcd03ccf8c63c28c616fe714d80475ef731f3b13dd21b" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-core", + "futures-util", + "http 1.2.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.74" @@ -484,6 +506,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -2174,6 +2207,7 @@ dependencies = [ "async-graphql-axum", "async-trait", "axum", + "axum-extra", "bcrypt", "chrono", "chrono-tz", diff --git a/Cargo.toml b/Cargo.toml index 63667ac..09ccc21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" async-graphql = { version = "7.0.15", features = ["chrono"] } async-graphql-axum = "7.0.17" axum = "0.8.6" +axum-extra = { version = "0.12.2", features = ["cookie"] } chrono = { version = "0.4.42", features = ["clock"] } serde = { version = "1.0.219", features = ["derive"] } sqlx = { version = "0.8.6", features = ["chrono", "postgres", "runtime-tokio"] } diff --git a/src/auth/auth_service.rs b/src/auth/auth_service.rs new file mode 100644 index 0000000..6b6fbb6 --- /dev/null +++ b/src/auth/auth_service.rs @@ -0,0 +1,62 @@ +use crate::auth::oauth::GitHubOAuthService; +use crate::models::auth::{GitHubUser, Role}; +use crate::models::member::Member; +use chrono_tz::Asia::Kolkata; +use sqlx::PgPool; + +pub struct AuthService; + +impl AuthService { + pub async fn handle_github_callback(pool: &PgPool, code: String) -> Result { + let oauth_service = GitHubOAuthService::new() + .map_err(|e| format!("Failed to initialize OAuth service: {}", e))?; + + let github_user = oauth_service + .complete_oauth_flow(code) + .await + .map_err(|e| format!("OAuth flow failed: {}", e))?; + + let existing_member = sqlx::query_as::<_, Member>( + "SELECT member_id, roll_no, name, email, sex, year, hostel, mac_address, discord_id, + group_id, track, github_user, role, created_at, updated_at + FROM Member + WHERE github_user = $1", + ) + .bind(&github_user.github_username) + .fetch_optional(pool) + .await + .map_err(|e| format!("Failed to query member: {}", e))?; + + let member = if let Some(member) = existing_member { + // Member exists - return existing member + member + } else { + // Member doesn't exist - register new member + Self::register_member(pool, github_user).await? + }; + + Ok(member) + } + + async fn register_member(pool: &PgPool, github_user: GitHubUser) -> Result { + let now = chrono::Utc::now().with_timezone(&Kolkata); + + let member = sqlx::query_as::<_, Member>( + "INSERT INTO Member (name, email, github_user, role, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING member_id, roll_no, name, email, sex, year, hostel, mac_address, discord_id, + group_id, track, github_user, role, created_at, updated_at", + ) + .bind(github_user.name) + .bind(github_user.email) + .bind(github_user.github_username) + .bind(Role::Member) + .bind(now) + .bind(now) + .fetch_one(pool) + .await + .map_err(|e| format!("Failed to register member: {}", e))?; + + Ok(member) + } +} diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs index 567d298..174eef1 100644 --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -7,6 +7,7 @@ use axum::{ middleware::Next, response::Response, }; +use axum_extra::extract::CookieJar; use sqlx::PgPool; use std::sync::Arc; @@ -20,29 +21,22 @@ pub async fn auth_middleware( .get(AUTHORIZATION) .and_then(|h| h.to_str().ok()); - let member = if let Some(auth_value) = auth_header { - if let Some(token) = auth_value.strip_prefix("Bearer ").or(Some(auth_value)) { - let session_member = SessionService::validate_session(&pool, token) - .await - .ok() - .flatten(); + let jar = CookieJar::from_headers(request.headers()); - if session_member.is_some() { - session_member - } else { - // If session returned None, try API key - ApiKeyService::validate_api_key(&pool, token) - .await - .ok() - .flatten() - } - } else { - None - } + let member = if let Some(cookie) = jar.get("session_token") { + SessionService::validate_session(&pool, cookie.value()) + .await + .ok() + .flatten() + } else if let Some(auth_value) = auth_header { + let token = auth_value.strip_prefix("Bearer ").unwrap_or(auth_value); + ApiKeyService::validate_api_key(&pool, token) + .await + .ok() + .flatten() } else { None }; - // Inject auth context into request extensions request.extensions_mut().insert(AuthContext::new(member)); diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 6cb3c72..b296843 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,4 +1,5 @@ pub mod api_key; +pub mod auth_service; pub mod guards; pub mod middleware; pub mod oauth; diff --git a/src/graphql/mutations/auth_mutations.rs b/src/graphql/mutations/auth_mutations.rs index 710b712..06cffc7 100644 --- a/src/graphql/mutations/auth_mutations.rs +++ b/src/graphql/mutations/auth_mutations.rs @@ -1,12 +1,9 @@ use crate::auth::api_key::ApiKeyService; use crate::auth::guards::AdminGuard; -use crate::auth::oauth::GitHubOAuthService; use crate::auth::session::SessionService; use crate::auth::AuthContext; -use crate::models::auth::{ApiKeyResponse, AuthResponse, GitHubUser, Role}; -use crate::models::member::Member; +use crate::models::auth::ApiKeyResponse; use async_graphql::{Context, Object, Result}; -use chrono_tz::Asia::Kolkata; use sqlx::PgPool; use std::sync::Arc; @@ -15,49 +12,6 @@ pub struct AuthMutations; #[Object] impl AuthMutations { - /// Complete GitHub OAuth flow and create session (for registration or login) - #[graphql(name = "githubOAuthCallback")] - async fn github_oauth_callback(&self, ctx: &Context<'_>, code: String) -> Result { - let pool = ctx.data::>().expect("Pool must be in context."); - - let oauth_service = GitHubOAuthService::new() - .map_err(|e| format!("Failed to initialize OAuth service: {}", e))?; - - let github_user = oauth_service - .complete_oauth_flow(code) - .await - .map_err(|e| format!("OAuth flow failed: {}", e))?; - - // Check if member already exists - let existing_member = sqlx::query_as::<_, Member>( - "SELECT member_id, roll_no, name, email, sex, year, hostel, mac_address, discord_id, - group_id, track, github_user, role, created_at, updated_at - FROM Member - WHERE github_user = $1", - ) - .bind(&github_user.github_username) - .fetch_optional(pool.as_ref()) - .await?; - - let member = if let Some(member) = existing_member { - // Member exists - login - member - } else { - // Member doesn't exist - register - Self::register_member(pool.as_ref(), github_user).await? - }; - - // Create session - let session_token = SessionService::create_session(pool.as_ref(), member.member_id) - .await - .map_err(|e| format!("Failed to create session: {}", e))?; - - Ok(AuthResponse { - member, - session_token, - }) - } - /// Logout - invalidate session #[graphql(name = "logout")] async fn logout(&self, ctx: &Context<'_>, session_token: String) -> Result { @@ -109,26 +63,3 @@ impl AuthMutations { Ok(true) } } - -impl AuthMutations { - async fn register_member(pool: &PgPool, github_user: GitHubUser) -> Result { - let now = chrono::Utc::now().with_timezone(&Kolkata); - - let member = sqlx::query_as::<_, Member>( - "INSERT INTO Member (name, email, github_user, role, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING member_id, roll_no, name, email, sex, year, hostel, mac_address, discord_id, - group_id, track, github_user, role, created_at, updated_at", - ) - .bind(github_user.name) - .bind(github_user.email) - .bind(github_user.github_username) - .bind(Role::Member) - .bind(now) - .bind(now) - .fetch_one(pool) - .await?; - - Ok(member) - } -} diff --git a/src/routes.rs b/src/routes.rs index d4a81e5..0d5d921 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -2,27 +2,38 @@ use async_graphql::{http::GraphiQLSource, EmptySubscription, Schema}; use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; use axum::{ extract::{Extension, Query as AxumQuery, State}, + http::{header, StatusCode}, middleware, response::{Html, IntoResponse, Redirect}, routing::{get, post}, - Json, Router, + Router, }; -use serde::{Deserialize, Serialize}; +use axum_extra::extract::cookie::{Cookie, SameSite}; +use serde::Deserialize; use sqlx::PgPool; use std::sync::Arc; use tower_http::cors::CorsLayer; +use crate::auth::auth_service::AuthService; use crate::auth::middleware::auth_middleware; use crate::auth::oauth::GitHubOAuthService; +use crate::auth::session::SessionService; use crate::auth::AuthContext; use crate::graphql::{Mutation, Query}; +#[derive(Clone)] +struct AppState { + schema: Schema, + pool: Arc, +} + async fn graphql_handler( - State(schema): State>, + State(state): State, Extension(auth_context): Extension, req: GraphQLRequest, ) -> GraphQLResponse { - schema + state + .schema .execute(req.into_inner().data(auth_context)) .await .into() @@ -35,6 +46,7 @@ pub fn setup_router( pool: Arc, ) -> Router { let pool_for_middleware = pool.clone(); + let app_state = AppState { schema, pool }; let mut router = Router::new() .route("/", post(graphql_handler)) @@ -51,7 +63,7 @@ pub fn setup_router( auth_middleware(pool_for_middleware.clone(), req, next) })) .layer(cors) - .with_state(schema) + .with_state(app_state) } async fn graphiql() -> impl IntoResponse { @@ -72,48 +84,49 @@ async fn github_oauth_init() -> Result { let (auth_url, _csrf_token) = oauth_service.get_authorization_url(); - // In production grade systems, we should be storing the token on the server side and returning - // a session ID to the frontend. When the frontend calls the server for the authorization - // code exchange, it should include the ID [eg: as a cookie] so that the backend can verify - // that the state parameter from the authorization server matches it. This is to prevent CSRF - // attacks. - // - // For now, we'll just redirect Ok(Redirect::temporary(&auth_url)) } #[derive(Deserialize)] struct OAuthCallbackQuery { code: String, + // In a production system, this state variable should be populated + // and verified using server side cookies. For now, we'll ignore it. #[allow(dead_code)] state: Option, } -#[derive(Serialize)] -struct OAuthCallbackResponse { - success: bool, - message: String, - #[serde(skip_serializing_if = "Option::is_none")] - redirect_url: Option, -} - -/// Sample GitHub OAuth callback (used for testing purposes) +/// GitHub OAuth callback handler - completes authentication and sets session cookie async fn github_oauth_callback( + State(state): State, AxumQuery(query): AxumQuery, -) -> Json { - // In a real implementation, you should: - // 0. Handle this callback in the frontend - // 1. Verify the CSRF token (state parameter) - // 2. Call the githubOAuthCallback GraphQL mutation with the code from the URL parameter - // 3. Store the session token and use it for authentication. - - // For now, we'll return a response that the frontend can handle - Json(OAuthCallbackResponse { - success: true, - message: format!( - "OAuth callback received. Use code '{}' with githubOAuthCallback mutation.", - query.code - ), - redirect_url: Some(format!("/graphql?code={}", query.code)), - }) +) -> Result { + let frontend_url = std::env::var("FRONTEND_URL").expect("FRONTEND_URL not set"); + + let member = AuthService::handle_github_callback(state.pool.as_ref(), query.code) + .await + .map_err(|e| (StatusCode::UNAUTHORIZED, format!("OAuth failed: {}", e)))?; + + let session_token = SessionService::create_session(state.pool.as_ref(), member.member_id) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create session: {}", e), + ) + })?; + + let cookie = Cookie::build(("session_token", session_token)) + .path("/") + .http_only(true) + .secure(true) + .same_site(SameSite::Lax) + .max_age(time::Duration::days(30)) + .build(); + + // Redirect to frontend with cookie + Ok(( + [(header::SET_COOKIE, cookie.to_string())], + Redirect::to(&frontend_url), + )) } From 791fe98cea9492b8e2b1fc58c9c13aa1dd198fe0 Mon Sep 17 00:00:00 2001 From: "Hridesh MG (aider)" Date: Fri, 21 Nov 2025 15:39:10 +0530 Subject: [PATCH 17/21] docs: Update auth.md for cookie-based OAuth and session management --- docs/auth.md | 156 ++++++++++++++++----------------------------------- 1 file changed, 48 insertions(+), 108 deletions(-) diff --git a/docs/auth.md b/docs/auth.md index f9a1a2e..f624c4e 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -65,7 +65,8 @@ Add the following to your `.env` file: # GitHub OAuth credentials GITHUB_CLIENT_ID=your_client_id_here GITHUB_CLIENT_SECRET=your_client_secret_here -GITHUB_REDIRECT_URL=http://localhost:5000/auth/github/callback +GITHUB_REDIRECT_URL=http://localhost:5000/auth/github/callback # Oauth Callback +FRONTEND_URL=http://localhost:3000/dashboard # Redirect after OAuth GITHUB_ORG_NAME=amfoss # Organization that users must be part of ``` @@ -105,49 +106,14 @@ Now you can create bots and manage the system! ### Member Registration & Login (GitHub OAuth) +The OAuth flow is now handled entirely by the backend, simplifying the frontend implementation. -1. User visits: `http://localhost:5000/auth/github` -2. Gets redirected to GitHub for authorization -3. After authorization, GitHub redirects to the frontend at: `/auth/github/callback?code=...` -4. Frontend receives the OAuth code -5. Frontend calls GraphQL mutation with the code and the backend returns the session token for the user: - -```graphql -mutation { - githubOAuthCallback(code: "oauth_code_here") { - member { - memberId - name - email - role - githubUser - } - sessionToken - } -} -``` - -**Response:** -```json -{ - "data": { - "githubOAuthCallback": { - "member": { - "memberId": 1, - "githubUser": "johndoe", - "name": "John Doe", - "email": "john@example.com", - "role": "Member" - }, - "sessionToken": "abc123...xyz789" - } - } -} -``` - -6. Frontend stores the session token (in localStorage, cookie, etc.) -7. Frontend includes token in subsequent requests via Authorization header - +1. **Initiate Login**: The user visits `http://localhost:5000/auth/github`. +2. **GitHub Authorization**: The user is redirected to GitHub to authorize the application. +3. **Backend Callback**: After authorization, GitHub redirects the user to the backend's callback URL: `http://localhost:5000/auth/github/callback`. +4. **Session Creation**: The backend exchanges the OAuth code for an access token, fetches user info, and either registers a new member or logs in an existing one. A session is created for the user. +5. **Cookie and Redirect**: The backend sets a secure, HTTP-only `session_token` cookie in the user's browser and redirects them to the `FRONTEND_URL` specified in your `.env` file. +6. **Authenticated State**: The user is now logged in. The browser will automatically send the session cookie with all subsequent requests to the backend API. **Important:** - First time users are automatically registered @@ -156,16 +122,19 @@ mutation { ### Making Authenticated Requests -Include the session token in the Authorization header: +**For Members (Browser):** +After logging in via GitHub OAuth, the browser automatically handles authentication by sending the `session_token` cookie with every request. No manual header management is needed in the frontend code. +**For Bots (API Keys):** +Bots must include their API key in the `Authorization` header: ``` -Authorization: Bearer +Authorization: Bearer ``` -Example with curl: +Example with curl (simulating a bot request): ```bash -curl -X POST http://localhost:3000/ \ - -H "Authorization: Bearer abc123...xyz789" \ +curl -X POST http://localhost:5000/ \ + -H "Authorization: Bearer root_wnTK5uRq8FECFSvSC8OVZ8h0SSJefTMlvGWJmsS4" \ -H "Content-Type: application/json" \ -d '{ "query": "{ member(memberId: 1) { name email } }" @@ -278,19 +247,6 @@ The GitHub account is not part of the specified organization. Either: ### GraphQL Mutations -#### `githubOAuthCallback(code: String!): AuthResponse!` - -Complete OAuth flow and create session. - -**Input:** -- `code`: OAuth authorization code from GitHub - -**Returns:** -- `member`: Member information including memberId, name, email, role, githubUser -- `sessionToken`: Session token for subsequent requests - ---- - #### `logout(sessionToken: String!): Boolean!` Invalidate the specified session for current user. @@ -327,58 +283,42 @@ Delete a bot and revoke its API key. ### Complete Member Authentication Flow ```javascript -// 1. Redirect to GitHub OAuth -window.location.href = 'http://localhost:3000/auth/github'; - -// 2. After callback, extract code from URL -const urlParams = new URLSearchParams(window.location.search); -const code = urlParams.get('code'); - -// 3. Call GraphQL mutation -const response = await fetch('http://localhost:3000/', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: ` - mutation($code: String!) { - githubOAuthCallback(code: $code) { - member { memberId name email role } - sessionToken - } - } - `, - variables: { code } - }) -}); - -const { data } = await response.json(); -const sessionToken = data.githubOAuthCallback.sessionToken; - -// 4. Store token -localStorage.setItem('sessionToken', sessionToken); - -// 5. Use token in subsequent requests -fetch('http://localhost:3000/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${sessionToken}` - }, - body: JSON.stringify({ - query: '{ member(memberId: 1) { name } }' - }) -}); +// 1. Redirect user to the backend's OAuth initiation endpoint +function login() { + window.location.href = 'http://localhost:5000/auth/github'; +} + +// After the user authorizes on GitHub, the backend handles everything +// and redirects the user back to the frontend (e.g., to the dashboard). +// The user is now authenticated, and the session cookie is set. + +// 2. Make authenticated requests +// The browser will automatically include the session cookie. +// No need to manually set Authorization headers. +async function fetchMemberData() { + const response = await fetch('http://localhost:5000/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: '{ member(memberId: 1) { name } }' + }) + }); + + const result = await response.json(); + console.log(result.data.member); +} ``` ## Architecture Notes ### Authentication Flow -1. **Request arrives** → `auth_middleware` extracts Authorization header -2. **Token validation** → Tries session token first, then API key -3. **Member lookup** → Returns associated Member or None -4. **Context injection** → AuthContext with Member is added to request extensions -5. **GraphQL execution** → Guards check AuthContext for permissions -6. **Response** → Returns data or permission error +1. **Request arrives** → `auth_middleware` is executed. +2. **Cookie check** → The middleware first checks for a `session_token` cookie. If valid, the associated member is found. +3. **API Key check** → If no valid session cookie is found, it checks the `Authorization: Bearer ` header for an API key. +4. **Member lookup** → If a valid key is found, the associated bot member is retrieved. +5. **Context injection** → An `AuthContext` with the member (or `None`) is added to the request extensions. +6. **GraphQL execution** → Guards check `AuthContext` for permissions. +7. **Response** → Returns data or a permission error. ### Bot Members From 869227c42479badd6e64aaf59c0e4967eee4a86f Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Fri, 21 Nov 2025 18:50:11 +0530 Subject: [PATCH 18/21] fix: Configure CORS and cookie settings for production deployment --- src/main.rs | 10 ++++++---- src/routes.rs | 4 +++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index d072c27..327d6f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ use async_graphql::EmptySubscription; +use axum::http::header::CONTENT_TYPE; use axum::http::{HeaderValue, Method}; use sqlx::Executor; use sqlx::PgPool; @@ -147,14 +148,15 @@ fn build_graphql_schema( fn setup_cors() -> CorsLayer { // TODO: Replace hardcoded strings - let _origins: [HeaderValue; 2] = [ - "http://127.0.0.1:3000".parse().unwrap(), + let origins: [HeaderValue; 2] = [ + "http://localhost:3000".parse().unwrap(), "https://home.amfoss.in".parse().unwrap(), ]; CorsLayer::new() // TODO 2: https://github.com/amfoss/root/issues/151, enabling all origins for the time being - .allow_origin(Any) + .allow_credentials(true) + .allow_origin(origins) .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) - .allow_headers(tower_http::cors::Any) + .allow_headers([CONTENT_TYPE]) } diff --git a/src/routes.rs b/src/routes.rs index 0d5d921..58b7448 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -102,6 +102,7 @@ async fn github_oauth_callback( AxumQuery(query): AxumQuery, ) -> Result { let frontend_url = std::env::var("FRONTEND_URL").expect("FRONTEND_URL not set"); + let hostname = std::env::var("HOSTNAME").expect("HOSTNAME not set"); let member = AuthService::handle_github_callback(state.pool.as_ref(), query.code) .await @@ -119,7 +120,8 @@ async fn github_oauth_callback( let cookie = Cookie::build(("session_token", session_token)) .path("/") .http_only(true) - .secure(true) + .secure(false) + .domain(hostname) .same_site(SameSite::Lax) .max_age(time::Duration::days(30)) .build(); From a40eb253db3531785218a35bb830395db5e53f90 Mon Sep 17 00:00:00 2001 From: "Hridesh MG (aider)" Date: Fri, 21 Nov 2025 18:50:13 +0530 Subject: [PATCH 19/21] refactor: centralize config and set secure cookie based on environment --- .env.sample | 6 ++++-- src/main.rs | 15 ++++++++++----- src/routes.rs | 29 ++++++++++++++--------------- 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/.env.sample b/.env.sample index 8bc8948..793c47e 100644 --- a/.env.sample +++ b/.env.sample @@ -13,9 +13,11 @@ ROOT_PORT=3000 # GitHub OAuth for authentication GITHUB_CLIENT_ID=your_github_oauth_app_client_id GITHUB_CLIENT_SECRET=your_github_oauth_app_client_secret -GITHUB_REDIRECT_URL=http://localhost:5000/auth/github/callback #Oauth Callback -FRONTEND_URL=http://localhost:3000/dashboard #Redirect after OAuth +GITHUB_REDIRECT_URL=http://localhost:5000/auth/github/callback # Oauth Callback GITHUB_ORG_NAME=amfoss +FRONTEND_URL=http://localhost:3000/dashboard # Redirect here after OAuth +HOSTNAME=localhost + # Seed toggle SEEDING_ENABLED=false diff --git a/src/main.rs b/src/main.rs index 327d6f3..f9d71b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use sqlx::Executor; use sqlx::PgPool; use std::sync::Arc; use time::UtcOffset; -use tower_http::cors::{Any, CorsLayer}; +use tower_http::cors::CorsLayer; use tracing::info; use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; @@ -23,12 +23,15 @@ pub mod routes; /// Handles all over environment variables in one place. // TODO: Replace with `Config.rs` crate. -struct Config { - env: String, +#[derive(Clone)] +pub struct Config { + pub env: String, secret_key: String, database_url: String, port: String, seeding_enabled: bool, + pub frontend_url: String, + pub hostname: String, } impl Config { @@ -42,6 +45,8 @@ impl Config { seeding_enabled: std::env::var("SEEDING_ENABLED") .map(|v| v.to_lowercase() == "true") .unwrap_or(false), + frontend_url: std::env::var("FRONTEND_URL").expect("FRONTEND_URL not set"), + hostname: std::env::var("HOSTNAME").expect("HOSTNAME not set"), } } } @@ -52,7 +57,7 @@ async fn main() { setup_tracing(&config.env); let pool = setup_database(&config.database_url).await; - let schema = build_graphql_schema(pool.clone(), config.secret_key); + let schema = build_graphql_schema(pool.clone(), config.secret_key.clone()); if config.seeding_enabled { info!("Seeding database..."); @@ -65,7 +70,7 @@ async fn main() { }); let cors = setup_cors(); - let router = setup_router(schema, cors, config.env == "development", pool); + let router = setup_router(schema, cors, config.clone(), pool); info!("Starting Root..."); let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port)) diff --git a/src/routes.rs b/src/routes.rs index 58b7448..2737973 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -20,11 +20,13 @@ use crate::auth::oauth::GitHubOAuthService; use crate::auth::session::SessionService; use crate::auth::AuthContext; use crate::graphql::{Mutation, Query}; +use crate::Config; #[derive(Clone)] struct AppState { schema: Schema, pool: Arc, + config: Config, } async fn graphql_handler( @@ -42,21 +44,21 @@ async fn graphql_handler( pub fn setup_router( schema: Schema, cors: CorsLayer, - is_dev: bool, + config: Config, pool: Arc, ) -> Router { let pool_for_middleware = pool.clone(); - let app_state = AppState { schema, pool }; + let app_state = AppState { + schema, + pool, + config: config.clone(), + }; - let mut router = Router::new() + let router = Router::new() .route("/", post(graphql_handler)) .route("/auth/github", get(github_oauth_init)) - .route("/auth/github/callback", get(github_oauth_callback)); - - if is_dev { - tracing::info!("GraphiQL playground enabled at /graphiql"); - router = router.route("/graphiql", get(graphiql).post(graphql_handler)); - } + .route("/auth/github/callback", get(github_oauth_callback)) + .route("/graphiql", get(graphiql).post(graphql_handler)); router .layer(middleware::from_fn(move |req, next| { @@ -101,9 +103,6 @@ async fn github_oauth_callback( State(state): State, AxumQuery(query): AxumQuery, ) -> Result { - let frontend_url = std::env::var("FRONTEND_URL").expect("FRONTEND_URL not set"); - let hostname = std::env::var("HOSTNAME").expect("HOSTNAME not set"); - let member = AuthService::handle_github_callback(state.pool.as_ref(), query.code) .await .map_err(|e| (StatusCode::UNAUTHORIZED, format!("OAuth failed: {}", e)))?; @@ -120,8 +119,8 @@ async fn github_oauth_callback( let cookie = Cookie::build(("session_token", session_token)) .path("/") .http_only(true) - .secure(false) - .domain(hostname) + .secure(state.config.env != "development") + .domain(state.config.hostname) .same_site(SameSite::Lax) .max_age(time::Duration::days(30)) .build(); @@ -129,6 +128,6 @@ async fn github_oauth_callback( // Redirect to frontend with cookie Ok(( [(header::SET_COOKIE, cookie.to_string())], - Redirect::to(&frontend_url), + Redirect::to(&state.config.frontend_url), )) } From 4e4749333676925c366d622344d6b9821684a27a Mon Sep 17 00:00:00 2001 From: "Hridesh MG (aider)" Date: Fri, 21 Nov 2025 21:42:24 +0530 Subject: [PATCH 20/21] feat: Add /me endpoint and only allow user to update their own details --- docs/auth.md | 1 - src/graphql/mutations/member_mutations.rs | 42 ++++++----------------- src/graphql/queries/member_queries.rs | 11 ++++++ src/models/member.rs | 1 - 4 files changed, 22 insertions(+), 33 deletions(-) diff --git a/docs/auth.md b/docs/auth.md index f624c4e..c9339c6 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -37,7 +37,6 @@ Note that unauthenticated users have essentially no access to the system. ### Protected Mutations The following mutations require Admin or Bot role: -- `createMember` - `markAttendance` - `markStatusUpdate` - `createStatusBreak` diff --git a/src/graphql/mutations/member_mutations.rs b/src/graphql/mutations/member_mutations.rs index 010ef3e..515de22 100644 --- a/src/graphql/mutations/member_mutations.rs +++ b/src/graphql/mutations/member_mutations.rs @@ -1,6 +1,6 @@ -use crate::auth::guards::{AdminOrBotGuard, AuthGuard}; -use crate::models::auth::Role; -use crate::models::member::{CreateMemberInput, Member, UpdateMemberInput}; +use crate::auth::guards::AuthGuard; +use crate::auth::AuthContext; +use crate::models::member::{Member, UpdateMemberInput}; use async_graphql::{Context, Object, Result}; use sqlx::PgPool; use std::sync::Arc; @@ -10,35 +10,15 @@ pub struct MemberMutations; #[Object] impl MemberMutations { - #[graphql(name = "createMember", guard = "AdminOrBotGuard")] - async fn create_member(&self, ctx: &Context<'_>, input: CreateMemberInput) -> Result { + /// Update the details of the currently logged in member + #[graphql(name = "updateMe", guard = "AuthGuard")] + async fn update_me(&self, ctx: &Context<'_>, input: UpdateMemberInput) -> Result { let pool = ctx.data::>().expect("Pool must be in context."); + let auth = ctx + .data::() + .expect("AuthContext must be in context."); - let member = sqlx::query_as::<_, Member>( - "INSERT INTO Member (roll_no, name, email, sex, year, hostel, mac_address, discord_id, group_id, track, github_user, role) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *" - ) - .bind(&input.roll_no) - .bind(&input.name) - .bind(&input.email) - .bind(input.sex) - .bind(input.year) - .bind(&input.hostel) - .bind(&input.mac_address) - .bind(&input.discord_id) - .bind(input.group_id) - .bind(&input.track) - .bind(&input.github_user) - .bind(Role::Member) - .fetch_one(pool.as_ref()) - .await?; - - Ok(member) - } - - #[graphql(name = "updateMember", guard = "AuthGuard")] - async fn update_member(&self, ctx: &Context<'_>, input: UpdateMemberInput) -> Result { - let pool = ctx.data::>().expect("Pool must be in context."); + let logged_in_user = auth.user.as_ref().ok_or("User not found in context")?; let member = sqlx::query_as::<_, Member>( "UPDATE Member SET @@ -67,7 +47,7 @@ impl MemberMutations { .bind(input.group_id) .bind(&input.track) .bind(&input.github_user) - .bind(input.member_id) + .bind(logged_in_user.member_id) .fetch_one(pool.as_ref()) .await?; diff --git a/src/graphql/queries/member_queries.rs b/src/graphql/queries/member_queries.rs index 98443e0..468a0b0 100644 --- a/src/graphql/queries/member_queries.rs +++ b/src/graphql/queries/member_queries.rs @@ -1,4 +1,5 @@ use crate::auth::guards::AuthGuard; +use crate::auth::AuthContext; use crate::models::{attendance::AttendanceRecord, status_update::StatusUpdateRecord}; use async_graphql::{ComplexObject, Context, Object, Result}; use chrono::NaiveDate; @@ -49,6 +50,7 @@ impl MemberQueries { Ok(members) } + /// Fetch the details of a specific member #[graphql(guard = "AuthGuard")] async fn member( &self, @@ -78,6 +80,15 @@ impl MemberQueries { (None, None) => Err("Provide either member_id or email".into()), } } + + /// Fetch the details of the currently logged in member + #[graphql(guard = "AuthGuard")] + async fn me(&self, ctx: &Context<'_>) -> Result { + let auth = ctx.data::()?; + + // The AuthGuard ensures that the user is authenticated, so we can unwrap here. + Ok(auth.user.clone().unwrap()) + } } #[Object] diff --git a/src/models/member.rs b/src/models/member.rs index 45a701f..21569fa 100644 --- a/src/models/member.rs +++ b/src/models/member.rs @@ -48,7 +48,6 @@ pub struct CreateMemberInput { #[derive(InputObject)] pub struct UpdateMemberInput { - pub member_id: i32, pub roll_no: Option, pub name: Option, pub email: Option, From 4a9fdcd3a630845937ca5a7a824c59b97fa0ef0f Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Fri, 21 Nov 2025 22:59:43 +0530 Subject: [PATCH 21/21] fix: remove logout as session tokens are HttpOnly --- docs/auth.md | 44 ------------------------- src/auth/session.rs | 17 ---------- src/graphql/mutations/auth_mutations.rs | 31 ----------------- src/main.rs | 2 +- 4 files changed, 1 insertion(+), 93 deletions(-) diff --git a/docs/auth.md b/docs/auth.md index c9339c6..6ddd7c8 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -140,18 +140,6 @@ curl -X POST http://localhost:5000/ \ }' ``` -### Logout - -```graphql -mutation { - logout(sessionToken: "your_session_token_here") -} -``` - -This invalidates the specified session for the current user. - -**Returns:** `true` if successful, `false` if not authenticated - ## Bot Management ### Creating Bots (Admin Only) @@ -180,16 +168,6 @@ mutation { Bots use API keys instead of session tokens. Include the API key in the `Authorization` header in the same format as before. -### Deleting Bots (Admin Only) - -```graphql -mutation { - deleteBot(apiKeyId: 1) -} -``` - -**Returns:** `true` if successful - ## Permission Checking in Code @@ -246,17 +224,6 @@ The GitHub account is not part of the specified organization. Either: ### GraphQL Mutations -#### `logout(sessionToken: String!): Boolean!` - -Invalidate the specified session for current user. - -**Input:** -- `sessionToken`: The session token to invalidate - -**Returns:** `true` if successful, `false` if not authenticated - ---- - #### `createBot(name: String!): String!` 🔒 Admin only Create a new bot with API key. @@ -266,17 +233,6 @@ Create a new bot with API key. **Returns:** The API key string (only shown once!) ---- - -#### `deleteBot(apiKeyId: Int!): Boolean!` 🔒 Admin only - -Delete a bot and revoke its API key. - -**Input:** -- `apiKeyId`: ID of the API key to delete - -**Returns:** `true` if successful - ## Example ### Complete Member Authentication Flow diff --git a/src/auth/session.rs b/src/auth/session.rs index 43000c5..1a79867 100644 --- a/src/auth/session.rs +++ b/src/auth/session.rs @@ -73,23 +73,6 @@ impl SessionService { Ok(result) } - pub async fn delete_session_by_token(pool: &PgPool, token: &str) -> Result<(), String> { - let token_hash = Self::hash_token(token); - - sqlx::query( - r#" - DELETE FROM Sessions - WHERE token_hash = $1 - "#, - ) - .bind(token_hash) - .execute(pool) - .await - .map_err(|e| format!("Failed to delete session: {}", e))?; - - Ok(()) - } - pub async fn cleanup_expired_sessions(pool: &PgPool) -> Result { let now = chrono::Utc::now().with_timezone(&Kolkata); diff --git a/src/graphql/mutations/auth_mutations.rs b/src/graphql/mutations/auth_mutations.rs index 06cffc7..963a775 100644 --- a/src/graphql/mutations/auth_mutations.rs +++ b/src/graphql/mutations/auth_mutations.rs @@ -1,6 +1,5 @@ use crate::auth::api_key::ApiKeyService; use crate::auth::guards::AdminGuard; -use crate::auth::session::SessionService; use crate::auth::AuthContext; use crate::models::auth::ApiKeyResponse; use async_graphql::{Context, Object, Result}; @@ -12,24 +11,6 @@ pub struct AuthMutations; #[Object] impl AuthMutations { - /// Logout - invalidate session - #[graphql(name = "logout")] - async fn logout(&self, ctx: &Context<'_>, session_token: String) -> Result { - let pool = ctx.data::>().expect("Pool must be in context."); - let auth = ctx - .data::() - .expect("AuthContext must be in context."); - - if auth.is_authenticated() { - SessionService::delete_session_by_token(pool.as_ref(), &session_token) - .await - .map_err(|e| format!("Failed to logout: {}", e))?; - Ok(true) - } else { - Ok(false) - } - } - /// Create a new bot with API key (Admin only) #[graphql(name = "createBot", guard = "AdminGuard")] async fn create_bot(&self, ctx: &Context<'_>, name: String) -> Result { @@ -50,16 +31,4 @@ impl AuthMutations { Ok(ApiKeyResponse { api_key }) } - - /// Delete a bot (Admin only) - #[graphql(name = "deleteBot", guard = "AdminGuard")] - async fn delete_bot(&self, ctx: &Context<'_>, api_key_id: i32) -> Result { - let pool = ctx.data::>().expect("Pool must be in context."); - - ApiKeyService::delete_api_key(pool.as_ref(), api_key_id) - .await - .map_err(|e| format!("Failed to delete bot: {}", e))?; - - Ok(true) - } } diff --git a/src/main.rs b/src/main.rs index f9d71b9..8332d7a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -159,7 +159,7 @@ fn setup_cors() -> CorsLayer { ]; CorsLayer::new() - // TODO 2: https://github.com/amfoss/root/issues/151, enabling all origins for the time being + // TODO 2: https://github.com/amfoss/root/issues/151 .allow_credentials(true) .allow_origin(origins) .allow_methods([Method::GET, Method::POST, Method::OPTIONS])