diff --git a/backend/Cargo.lock b/backend/Cargo.lock index ae1167f..629bc07 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -24,6 +24,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "argon2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.3.1", + "password-hash", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -99,12 +111,46 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.2" @@ -123,6 +169,24 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.12.1" @@ -190,7 +254,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -210,6 +274,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.3" @@ -236,6 +306,17 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -252,6 +333,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -297,6 +387,28 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -315,14 +427,64 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + [[package]] name = "darling" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", ] [[package]] @@ -338,13 +500,24 @@ dependencies = [ "syn 3.0.10", ] +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling_macro" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core", + "darling_core 0.24.1", "quote", "syn 3.0.10", ] @@ -355,6 +528,17 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.9" @@ -383,15 +567,27 @@ dependencies = [ "syn 3.0.10", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", + "subtle", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.12.1", + "const-oid 0.10.3", + "crypto-common 0.2.2", "ctutils", ] @@ -412,12 +608,71 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -434,6 +689,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.15" @@ -520,6 +791,17 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -559,6 +841,17 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -641,13 +934,31 @@ dependencies = [ "tracing", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hmac" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest", + "digest 0.11.3", ] [[package]] @@ -946,20 +1257,47 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" +dependencies = [ + "base64", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac 0.12.1", + "js-sys", + "p256", + "p384", + "rand 0.8.8", + "rsa", + "serde", + "serde_json", + "sha2 0.10.9", + "signature", + "zeroize", +] + [[package]] name = "kakeibo" version = "0.1.0" dependencies = [ + "argon2", "axum", + "axum-extra", "dotenvy", + "jsonwebtoken", "mongodb", "serde", "serde_json", "thiserror", + "time", "tokio", "tracing", "tracing-subscriber", "uuid", + "validator", ] [[package]] @@ -967,6 +1305,9 @@ name = "lazy_static" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -974,6 +1315,12 @@ version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "litemap" version = "0.8.3" @@ -1065,7 +1412,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest", + "digest 0.11.3", ] [[package]] @@ -1143,7 +1490,7 @@ dependencies = [ "hickory-net", "hickory-proto", "hickory-resolver", - "hmac", + "hmac 0.13.0", "macro_magic", "md-5", "mongocrypt", @@ -1157,7 +1504,7 @@ dependencies = [ "serde_bytes", "serde_with", "sha1", - "sha2", + "sha2 0.11.1", "socket2", "stringprep", "strsim", @@ -1198,12 +1545,47 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1211,6 +1593,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1223,6 +1606,30 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1246,13 +1653,32 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "getrandom 0.4.3", + "phc", +] + [[package]] name = "pbkdf2" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ - "digest", + "digest 0.11.3", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", ] [[package]] @@ -1261,12 +1687,44 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -1308,6 +1766,37 @@ dependencies = [ "num-traits", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.10", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1344,13 +1833,24 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -1365,6 +1865,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -1375,6 +1885,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rand_core" version = "0.9.5" @@ -1399,6 +1918,18 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.18" @@ -1422,6 +1953,16 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1436,6 +1977,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -1517,6 +2078,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.28" @@ -1617,7 +2192,7 @@ version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" dependencies = [ - "darling", + "darling 0.24.1", "proc-macro2", "quote", "syn 3.0.10", @@ -1630,8 +2205,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -1641,8 +2227,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -1670,6 +2256,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -1708,6 +2304,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2140,6 +2752,34 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "validator" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d68c6633c483df6780cc5277a417c7c2d1bceee2649d06c8ab6b0fd2dd3c81" +dependencies = [ + "idna", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335" +dependencies = [ + "darling 0.23.0", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "valuable" version = "0.1.1" @@ -2452,6 +3092,20 @@ name = "zeroize" version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 0104282..5168efc 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -4,13 +4,18 @@ version = "0.1.0" edition = "2024" [dependencies] +argon2 = "0.6.0" axum = "0.8.9" +axum-extra = { version = "0.12.6", features = ["cookie"] } dotenvy = "0.15.7" +jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] } mongodb = "3.10.0" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.154" thiserror = "2.0.21" +time = "0.3.55" tokio = { version = "1.54.1", features = ["full"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } uuid = { version = "1.29.2", features = ["v7"] } +validator = { version = "0.21.0", features = ["derive"] } diff --git a/backend/api.http b/backend/api.http index d1c61f1..aed0ee2 100644 --- a/backend/api.http +++ b/backend/api.http @@ -5,3 +5,33 @@ ### Sağlık kontrolü (DB erişilemezse 503 + "db": "error") GET {{baseUrl}}/health + +# ─────────────────────────── Kimlik doğrulama ─────────────────────────── +# REST Client cookie'leri otomatik saklar: kayıt/giriş sonrası diğer istekler oturumlu gider. + +### Kayıt (kullanıcı + hane + örnek kategoriler/ödeme türleri oluşur) +POST {{baseUrl}}/auth/register +Content-Type: application/json + +{ + "name": "Suphi", + "email": "suphi@ornek.com", + "password": "gizli-sifre-123", + "household_name": "Çakır Hanesi" +} + +### Giriş ("remember_me": true → 30 gün, false → tarayıcı kapanınca biter) +POST {{baseUrl}}/auth/login +Content-Type: application/json + +{ + "email": "suphi@ornek.com", + "password": "gizli-sifre-123", + "remember_me": true +} + +### Ben kimim? (oturum yoksa 401) +GET {{baseUrl}}/auth/me + +### Çıkış +POST {{baseUrl}}/auth/logout diff --git a/backend/src/auth/.gitkeep b/backend/src/auth/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/auth/jwt.rs b/backend/src/auth/jwt.rs new file mode 100644 index 0000000..0d7df7e --- /dev/null +++ b/backend/src/auth/jwt.rs @@ -0,0 +1,104 @@ +//! Oturum token'ı (JWT, HS256). + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use mongodb::bson::Uuid; +use serde::{Deserialize, Serialize}; + +use crate::{ + error::{AppError, AppResult}, + models::user::Role, +}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + /// Kullanıcı kimliği + pub sub: String, + /// Hane kimliği + pub hid: String, + pub role: Role, + pub iat: u64, + pub exp: u64, +} + +pub struct Jwt { + enc: EncodingKey, + dec: DecodingKey, +} + +impl Jwt { + pub fn new(secret: &str) -> Self { + Self { + enc: EncodingKey::from_secret(secret.as_bytes()), + dec: DecodingKey::from_secret(secret.as_bytes()), + } + } + + pub fn issue( + &self, + user_id: Uuid, + household_id: Uuid, + role: Role, + ttl: Duration, + ) -> AppResult { + let now = unix_now(); + let claims = Claims { + sub: user_id.to_string(), + hid: household_id.to_string(), + role, + iat: now, + exp: now + ttl.as_secs(), + }; + encode(&Header::default(), &claims, &self.enc) + .map_err(|e| AppError::Internal(format!("JWT üretilemedi: {e}"))) + } + + /// İmza ve süre geçerliyse claim'leri döner. + pub fn verify(&self, token: &str) -> Option { + decode::(token, &self.dec, &Validation::default()) + .ok() + .map(|t| t.claims) + } +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::common::new_id; + + const SECRET: &str = "test-secret-en-az-32-karakter-uzunlugunda!!"; + + #[test] + fn roundtrip() { + let jwt = Jwt::new(SECRET); + let (uid, hid) = (new_id(), new_id()); + let token = jwt + .issue(uid, hid, Role::Owner, Duration::from_secs(60)) + .unwrap(); + let c = jwt.verify(&token).unwrap(); + assert_eq!(c.sub, uid.to_string()); + assert_eq!(c.hid, hid.to_string()); + assert_eq!(c.role, Role::Owner); + } + + #[test] + fn rejects_wrong_secret_and_garbage() { + let token = Jwt::new(SECRET) + .issue(new_id(), new_id(), Role::Member, Duration::from_secs(60)) + .unwrap(); + assert!( + Jwt::new("baska-bir-gizli-anahtar-32-karakterden-uzun") + .verify(&token) + .is_none() + ); + assert!(Jwt::new(SECRET).verify("rastgele.metin.token").is_none()); + } +} diff --git a/backend/src/auth/limiter.rs b/backend/src/auth/limiter.rs new file mode 100644 index 0000000..b29fc9f --- /dev/null +++ b/backend/src/auth/limiter.rs @@ -0,0 +1,78 @@ +//! Basit, bellek içi giriş deneme sınırlayıcı. +//! +//! Anahtar: e-posta adresi. Pencere içinde `max_failures` hatalı denemeden sonra +//! pencere dolana kadar giriş engellenir. Başarılı girişte sayaç sıfırlanır. +//! (Tek sunucu için yeterli; sunucu yeniden başlarsa sayaçlar sıfırlanır.) + +use std::{ + collections::HashMap, + sync::Mutex, + time::{Duration, Instant}, +}; + +pub struct LoginLimiter { + attempts: Mutex>, + max_failures: u32, + window: Duration, +} + +impl LoginLimiter { + pub fn new(max_failures: u32, window: Duration) -> Self { + Self { + attempts: Mutex::new(HashMap::new()), + max_failures, + window, + } + } + + /// Bu anahtar için giriş denemesine izin var mı? + pub fn is_allowed(&self, key: &str) -> bool { + let map = self.attempts.lock().unwrap(); + match map.get(key) { + Some((count, since)) => *count < self.max_failures || since.elapsed() >= self.window, + None => true, + } + } + + pub fn record_failure(&self, key: &str) { + let mut map = self.attempts.lock().unwrap(); + // Süresi dolmuş kayıtları temizle (bellek büyümesin) + map.retain(|_, (_, since)| since.elapsed() < self.window); + let entry = map.entry(key.to_string()).or_insert((0, Instant::now())); + entry.0 += 1; + } + + pub fn reset(&self, key: &str) { + self.attempts.lock().unwrap().remove(key); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blocks_after_max_failures_and_resets() { + let l = LoginLimiter::new(3, Duration::from_secs(60)); + for _ in 0..3 { + assert!(l.is_allowed("a@b.c")); + l.record_failure("a@b.c"); + } + assert!(!l.is_allowed("a@b.c"), "3 hatadan sonra engellenmeli"); + assert!( + l.is_allowed("baska@b.c"), + "diğer kullanıcılar etkilenmemeli" + ); + l.reset("a@b.c"); + assert!(l.is_allowed("a@b.c")); + } + + #[test] + fn unblocks_after_window() { + let l = LoginLimiter::new(1, Duration::from_millis(20)); + l.record_failure("x"); + assert!(!l.is_allowed("x")); + std::thread::sleep(Duration::from_millis(30)); + assert!(l.is_allowed("x")); + } +} diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs new file mode 100644 index 0000000..d66acda --- /dev/null +++ b/backend/src/auth/mod.rs @@ -0,0 +1,8 @@ +//! Kimlik doğrulama: şifre hash'leme, JWT, oturum cookie'si, giriş deneme sınırı. + +pub mod jwt; +pub mod limiter; +pub mod password; +pub mod session; + +pub use session::AuthUser; diff --git a/backend/src/auth/password.rs b/backend/src/auth/password.rs new file mode 100644 index 0000000..3bc01f0 --- /dev/null +++ b/backend/src/auth/password.rs @@ -0,0 +1,76 @@ +//! Argon2id ile şifre hash'leme. +//! +//! Hash'leme bilerek yavaştır (kaba kuvvet saldırısını zorlaştırmak için), bu yüzden +//! async sunucuyu bloklamasın diye `spawn_blocking` ile ayrı bir iş parçacığında çalışır. + +use std::sync::LazyLock; + +use argon2::{ + Argon2, + password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash}, +}; + +use crate::error::{AppError, AppResult}; + +fn hash_sync(password: &str) -> AppResult { + Argon2::default() + .hash_password(password.as_bytes()) + .map(|h| h.to_string()) + .map_err(|e| AppError::Internal(format!("şifre hash'lenemedi: {e}"))) +} + +fn verify_sync(password: &str, hash: &str) -> bool { + PasswordHash::new(hash) + .map(|h| { + Argon2::default() + .verify_password(password.as_bytes(), &h) + .is_ok() + }) + .unwrap_or(false) +} + +/// Kullanıcı bulunamadığında da aynı sürede yanıt vermek için kullanılan sahte hash. +/// (Aksi halde yanıt süresinden "bu e-posta kayıtlı mı" anlaşılabilir.) +static DUMMY_HASH: LazyLock = + LazyLock::new(|| hash_sync("kakeibo-dummy-password").expect("dummy hash")); + +pub async fn hash(password: String) -> AppResult { + tokio::task::spawn_blocking(move || hash_sync(&password)) + .await + .map_err(|e| AppError::Internal(format!("hash görevi: {e}")))? +} + +/// `hash` `None` ise sahte hash ile doğrulama yapılır ve her zaman `false` döner. +pub async fn verify(password: String, hash: Option) -> bool { + tokio::task::spawn_blocking(move || match hash { + Some(h) => verify_sync(&password, &h), + None => { + let _ = verify_sync(&password, &DUMMY_HASH); + false + } + }) + .await + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_and_verify() { + let h = hash_sync("gizli-sifre-123").unwrap(); + assert!(h.starts_with("$argon2id$")); + assert!(verify_sync("gizli-sifre-123", &h)); + assert!(!verify_sync("yanlis-sifre", &h)); + } + + #[test] + fn same_password_different_hashes() { + // Her hash'te rastgele "tuz" (salt) kullanılır + assert_ne!( + hash_sync("abc12345").unwrap(), + hash_sync("abc12345").unwrap() + ); + } +} diff --git a/backend/src/auth/session.rs b/backend/src/auth/session.rs new file mode 100644 index 0000000..ab1b1d1 --- /dev/null +++ b/backend/src/auth/session.rs @@ -0,0 +1,65 @@ +//! Oturum cookie'si ve `AuthUser` extractor'ı. +//! +//! Bir route parametrelerine `user: AuthUser` eklediğinde, Axum isteği işlemeden önce +//! cookie'deki JWT'yi doğrular; geçersizse route hiç çalışmaz, 401 döner. + +use std::time::Duration; + +use axum::{extract::FromRequestParts, http::request::Parts}; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use mongodb::bson::Uuid; + +use crate::{error::AppError, models::user::Role, state::AppState}; + +pub const COOKIE_NAME: &str = "kakeibo_session"; + +/// "Beni hatırla" seçiliyse oturum süresi +pub const REMEMBER_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60); +/// "Beni hatırla" seçili değilse (tarayıcı kapanınca da cookie silinir) +pub const SESSION_TTL: Duration = Duration::from_secs(12 * 60 * 60); + +pub fn ttl(remember: bool) -> Duration { + if remember { REMEMBER_TTL } else { SESSION_TTL } +} + +pub fn session_cookie(token: String, remember: bool, secure: bool) -> Cookie<'static> { + let mut c = Cookie::build((COOKIE_NAME, token)) + .http_only(true) // JavaScript okuyamaz → XSS ile çalınamaz + .secure(secure) // yalnızca HTTPS'te gönderilir (canlıda) + .same_site(SameSite::Lax) // başka sitelerden gelen POST'larda gönderilmez + .path("/"); + if remember { + c = c.max_age(time::Duration::seconds(REMEMBER_TTL.as_secs() as i64)); + } + c.build() +} + +pub fn removal_cookie() -> Cookie<'static> { + Cookie::build((COOKIE_NAME, "")).path("/").build() +} + +/// Oturum açmış kullanıcı (JWT'den). +#[derive(Debug, Clone, Copy)] +pub struct AuthUser { + pub user_id: Uuid, + pub household_id: Uuid, + #[allow(dead_code)] // Faz 1 – Adım 4'te (davet, yetki kontrolü) kullanılacak + pub role: Role, +} + +impl FromRequestParts for AuthUser { + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + let jar = CookieJar::from_headers(&parts.headers); + let token = jar.get(COOKIE_NAME).ok_or(AppError::Unauthorized)?.value(); + let claims = state.jwt.verify(token).ok_or(AppError::Unauthorized)?; + + let parse = |s: &str| Uuid::parse_str(s).map_err(|_| AppError::Unauthorized); + Ok(Self { + user_id: parse(&claims.sub)?, + household_id: parse(&claims.hid)?, + role: claims.role, + }) + } +} diff --git a/backend/src/config.rs b/backend/src/config.rs index 316e098..c54ad1e 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -5,7 +5,6 @@ use std::env; #[derive(Debug, Clone)] pub struct Config { pub mongodb_uri: String, - #[allow(dead_code)] // Faz 1 – Adım 3'te (JWT) kullanılacak pub jwt_secret: String, pub app_url: String, pub port: u16, @@ -49,7 +48,6 @@ impl Config { } /// Cookie'lerde `Secure` bayrağı yalnızca HTTPS'te açılır. - #[allow(dead_code)] // Faz 1 – Adım 3'te (kimlik doğrulama) kullanılacak pub fn is_https(&self) -> bool { self.app_url.starts_with("https://") } diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index f13eab0..99a1e55 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -32,6 +32,20 @@ pub async fn ping(db: &Database) -> mongodb::error::Result<()> { db.run_command(doc! { "ping": 1 }).await.map(|_| ()) } +/// Benzersiz indeks ihlali mi? (ör. aynı e-postayla ikinci kayıt — hata kodu 11000) +pub fn is_duplicate_key(e: &mongodb::error::Error) -> bool { + use mongodb::error::{ErrorKind, WriteFailure}; + match e.kind.as_ref() { + ErrorKind::Write(WriteFailure::WriteError(we)) => we.code == 11000, + ErrorKind::Command(ce) => ce.code == 11000, + ErrorKind::InsertMany(ime) => ime + .write_errors + .as_ref() + .is_some_and(|errs| errs.iter().any(|we| we.code == 11000)), + _ => false, + } +} + /// Açılışta çalışır: bekleyen migration'lar, ardından indeksler. /// İkisi de tekrar çalıştırılabilir (idempotent); zaten yapılmış olanı atlar. pub async fn prepare(db: &Database) -> mongodb::error::Result<()> { diff --git a/backend/src/error.rs b/backend/src/error.rs index 1bdf554..ac2cc77 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -8,6 +8,7 @@ use axum::{ }; use serde_json::json; +#[allow(dead_code)] // NotFound/Forbidden: Faz 1 – Adım 4-5'te kullanılacak #[derive(Debug, thiserror::Error)] pub enum AppError { #[error("Kayıt bulunamadı")] @@ -16,6 +17,8 @@ pub enum AppError { BadRequest(String), #[error("Oturum açmanız gerekiyor")] Unauthorized, + #[error("E-posta veya şifre hatalı")] + InvalidCredentials, #[error("Bu işlem için yetkiniz yok")] Forbidden, #[error("{0}")] @@ -34,6 +37,7 @@ impl AppError { Self::NotFound => (StatusCode::NOT_FOUND, "not_found"), Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"), Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"), + Self::InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid_credentials"), Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"), Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"), Self::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"), @@ -64,5 +68,18 @@ impl IntoResponse for AppError { } } +/// Doğrulama hatası → ilk alanın Türkçe mesajıyla 400. +impl From for AppError { + fn from(errors: validator::ValidationErrors) -> Self { + let message = errors + .field_errors() + .values() + .flat_map(|list| list.iter()) + .find_map(|e| e.message.as_ref().map(|m| m.to_string())) + .unwrap_or_else(|| "Girilen bilgiler geçersiz".into()); + AppError::BadRequest(message) + } +} + /// Route fonksiyonlarında kısa dönüş tipi: `AppResult>` pub type AppResult = Result; diff --git a/backend/src/extract.rs b/backend/src/extract.rs new file mode 100644 index 0000000..398dfa5 --- /dev/null +++ b/backend/src/extract.rs @@ -0,0 +1,31 @@ +//! Özel extractor'lar. + +use axum::{ + Json, + extract::{FromRequest, Request}, +}; +use serde::de::DeserializeOwned; +use validator::Validate; + +use crate::error::AppError; + +/// `Json` gibi çalışır, ek olarak `validator` kurallarını da kontrol eder. +/// Hatalı JSON veya geçersiz alanlarda Türkçe 400 hatası döner. +pub struct ValidJson(pub T); + +impl FromRequest for ValidJson +where + T: DeserializeOwned + Validate, + S: Send + Sync, +{ + type Rejection = AppError; + + async fn from_request(req: Request, state: &S) -> Result { + let Json(value) = Json::::from_request(req, state).await.map_err(|e| { + tracing::debug!(error = %e, "JSON okunamadı"); + AppError::BadRequest("Gönderilen veri okunamadı veya eksik alan var".into()) + })?; + value.validate()?; + Ok(Self(value)) + } +} diff --git a/backend/src/main.rs b/backend/src/main.rs index b5c5e55..bf471d6 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,16 +1,22 @@ +mod auth; mod config; mod db; -#[allow(dead_code)] // AppError, Faz 1 – Adım 3 itibarıyla route'larda kullanılacak mod error; +mod extract; mod models; mod routes; +mod services; mod state; -use std::{net::SocketAddr, process::ExitCode, sync::Arc}; +use std::{net::SocketAddr, process::ExitCode, sync::Arc, time::Duration}; use tracing_subscriber::EnvFilter; -use crate::{config::Config, state::AppState}; +use crate::{ + auth::{jwt::Jwt, limiter::LoginLimiter}, + config::Config, + state::AppState, +}; #[tokio::main] async fn main() -> ExitCode { @@ -53,6 +59,9 @@ async fn main() -> ExitCode { let port = config.port; let state = AppState { db, + jwt: Arc::new(Jwt::new(&config.jwt_secret)), + // Aynı e-postaya 15 dakikada en fazla 5 hatalı giriş denemesi + login_limiter: Arc::new(LoginLimiter::new(5, Duration::from_secs(15 * 60))), config: Arc::new(config), }; let app = routes::router(state); diff --git a/backend/src/models/common.rs b/backend/src/models/common.rs index 78d6e8f..d329a49 100644 --- a/backend/src/models/common.rs +++ b/backend/src/models/common.rs @@ -3,8 +3,6 @@ //! Her kayıt şu alanları taşır: //! `_id` (UUID v7), `household_id`, `created_at`, `updated_at`, `deleted_at`, `version` -#![allow(dead_code)] // Faz 1 – Adım 3 itibarıyla kullanılacak - use mongodb::bson::{DateTime, Document, Uuid, doc}; /// Yeni UUID v7 üretir. İlk 48 bit zaman damgası olduğu için @@ -38,6 +36,7 @@ pub fn scope(household_id: Uuid) -> Document { doc! { "household_id": household_id, "deleted_at": null } } +#[allow(dead_code)] // Faz 1 – Adım 5'te (kategori güncelleme) kullanılacak /// Güncellemelerde eklenecek ortak kısım: `updated_at` yenilenir, `version` +1. pub fn touch() -> Document { doc! { diff --git a/backend/src/models/household.rs b/backend/src/models/household.rs new file mode 100644 index 0000000..a21df9f --- /dev/null +++ b/backend/src/models/household.rs @@ -0,0 +1,19 @@ +//! `households` koleksiyonu (DATA_MODEL.md §3.2). + +use mongodb::bson::{Document, doc}; +use serde::Deserialize; + +/// Veritabanından okunan hane (yalnızca ihtiyaç duyulan alanlar). +#[derive(Debug, Deserialize)] +pub struct Household { + pub name: String, +} + +/// Yeni hane için varsayılan ayarlar. +pub fn default_settings() -> Document { + doc! { + "currency": "TRY", + "budget_split_pct": { "needs": 50, "wants": 30, "culture": 10, "unexpected": 10 }, + "budget_suggestion": "default", + } +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index f4afdd2..42d8b8f 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -2,3 +2,5 @@ //! Şema: `docs/DATA_MODEL.md` pub mod common; +pub mod household; +pub mod user; diff --git a/backend/src/models/user.rs b/backend/src/models/user.rs new file mode 100644 index 0000000..5d75263 --- /dev/null +++ b/backend/src/models/user.rs @@ -0,0 +1,32 @@ +//! `users` koleksiyonu (DATA_MODEL.md §3.1). + +use mongodb::bson::Uuid; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { + Owner, + Member, +} + +impl Role { + pub fn as_str(self) -> &'static str { + match self { + Role::Owner => "owner", + Role::Member => "member", + } + } +} + +/// Veritabanından okunan kullanıcı (yalnızca ihtiyaç duyulan alanlar). +#[derive(Debug, Deserialize)] +pub struct User { + #[serde(rename = "_id")] + pub id: Uuid, + pub household_id: Uuid, + pub email: String, + pub password_hash: String, + pub name: String, + pub role: Role, +} diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs new file mode 100644 index 0000000..9dff7b1 --- /dev/null +++ b/backend/src/routes/auth.rs @@ -0,0 +1,210 @@ +//! Kimlik doğrulama uç noktaları: +//! `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me` + +use axum::{Json, extract::State, http::StatusCode}; +use axum_extra::extract::cookie::CookieJar; +use mongodb::bson::{Uuid, doc}; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +use crate::{ + auth::{ + AuthUser, password, + session::{removal_cookie, session_cookie, ttl}, + }, + db::col, + error::{AppError, AppResult}, + extract::ValidJson, + models::{ + common::{now, scope}, + household::Household, + user::{Role, User}, + }, + services::account::{self, NewAccount}, + state::AppState, +}; + +// ---------- İstek / yanıt yapıları ---------- + +#[derive(Deserialize, Validate)] +pub struct RegisterRequest { + #[validate(length(min = 1, max = 80, message = "Ad 1–80 karakter olmalı"))] + name: String, + #[validate(email(message = "Geçerli bir e-posta adresi girin"))] + email: String, + #[validate(length(min = 8, max = 128, message = "Şifre en az 8 karakter olmalı"))] + password: String, + #[validate(length(max = 80, message = "Hane adı en fazla 80 karakter olabilir"))] + household_name: Option, +} + +#[derive(Deserialize, Validate)] +pub struct LoginRequest { + #[validate(length(min = 1, message = "E-posta gerekli"))] + email: String, + #[validate(length(min = 1, message = "Şifre gerekli"))] + password: String, + #[serde(default)] + remember_me: bool, +} + +#[derive(Serialize)] +pub struct MeResponse { + user: UserDto, + household: HouseholdDto, +} + +#[derive(Serialize)] +struct UserDto { + id: String, + name: String, + email: String, + role: Role, +} + +#[derive(Serialize)] +struct HouseholdDto { + id: String, + name: String, +} + +// ---------- Uç noktalar ---------- + +pub async fn register( + State(state): State, + jar: CookieJar, + ValidJson(req): ValidJson, +) -> AppResult<(StatusCode, CookieJar, Json)> { + let name = req.name.trim().to_string(); + if name.is_empty() { + return Err(AppError::BadRequest("Ad boş olamaz".into())); + } + let email = normalize_email(&req.email); + let household_name = req + .household_name + .map(|h| h.trim().to_string()) + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| format!("{name} Hanesi")); + + let password_hash = password::hash(req.password).await?; + let created = account::register( + &state.db, + NewAccount { + name, + email, + password_hash, + household_name, + }, + ) + .await?; + + let token = state.jwt.issue( + created.user_id, + created.household_id, + Role::Owner, + ttl(true), + )?; + let jar = jar.add(session_cookie(token, true, state.config.is_https())); + + let me = load_me(&state, created.user_id, created.household_id).await?; + Ok((StatusCode::CREATED, jar, Json(me))) +} + +pub async fn login( + State(state): State, + jar: CookieJar, + ValidJson(req): ValidJson, +) -> AppResult<(CookieJar, Json)> { + let email = normalize_email(&req.email); + + if !state.login_limiter.is_allowed(&email) { + tracing::warn!(%email, "giriş deneme sınırı aşıldı"); + return Err(AppError::TooManyRequests); + } + + let user = state + .db + .collection::(col::USERS) + .find_one(doc! { "email": &email, "deleted_at": null }) + .await?; + + let hash = user.as_ref().map(|u| u.password_hash.clone()); + if !password::verify(req.password, hash).await { + state.login_limiter.record_failure(&email); + return Err(AppError::InvalidCredentials); + } + // verify() true döndüyse kullanıcı kesin vardır + let user = user.ok_or(AppError::InvalidCredentials)?; + state.login_limiter.reset(&email); + + state + .db + .collection::(col::USERS) + .update_one( + doc! { "_id": user.id }, + doc! { "$set": { "last_login_at": now() } }, + ) + .await?; + + let token = state + .jwt + .issue(user.id, user.household_id, user.role, ttl(req.remember_me))?; + let jar = jar.add(session_cookie( + token, + req.remember_me, + state.config.is_https(), + )); + + let me = load_me(&state, user.id, user.household_id).await?; + Ok((jar, Json(me))) +} + +pub async fn logout(jar: CookieJar) -> (StatusCode, CookieJar) { + (StatusCode::NO_CONTENT, jar.remove(removal_cookie())) +} + +pub async fn me(State(state): State, auth: AuthUser) -> AppResult> { + load_me(&state, auth.user_id, auth.household_id) + .await + .map(Json) +} + +// ---------- Yardımcılar ---------- + +fn normalize_email(email: &str) -> String { + email.trim().to_lowercase() +} + +/// Kullanıcı ve hanesini okur. Kullanıcı silinmişse oturum geçersiz sayılır. +async fn load_me(state: &AppState, user_id: Uuid, household_id: Uuid) -> AppResult { + let mut user_filter = scope(household_id); + user_filter.insert("_id", user_id); + let user = state + .db + .collection::(col::USERS) + .find_one(user_filter) + .await? + .ok_or(AppError::Unauthorized)?; + + let mut hh_filter = scope(household_id); + hh_filter.insert("_id", household_id); + let household = state + .db + .collection::(col::HOUSEHOLDS) + .find_one(hh_filter) + .await? + .ok_or(AppError::Unauthorized)?; + + Ok(MeResponse { + user: UserDto { + id: user.id.to_string(), + name: user.name, + email: user.email, + role: user.role, + }, + household: HouseholdDto { + id: household_id.to_string(), + name: household.name, + }, + }) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index b90dbd8..c4e4752 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -1,13 +1,22 @@ //! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında. +mod auth; mod health; -use axum::{Router, routing::get}; +use axum::{ + Router, + routing::{get, post}, +}; use crate::state::AppState; pub fn router(state: AppState) -> Router { - let api = Router::new().route("/health", get(health::health)); + let api = Router::new() + .route("/health", get(health::health)) + .route("/auth/register", post(auth::register)) + .route("/auth/login", post(auth::login)) + .route("/auth/logout", post(auth::logout)) + .route("/auth/me", get(auth::me)); Router::new().nest("/api", api).with_state(state) } diff --git a/backend/src/services/.gitkeep b/backend/src/services/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/services/account.rs b/backend/src/services/account.rs new file mode 100644 index 0000000..53c24b3 --- /dev/null +++ b/backend/src/services/account.rs @@ -0,0 +1,94 @@ +//! Hesap işlemleri: kayıt (kullanıcı + hane + başlangıç verileri). + +use mongodb::{ + Database, + bson::{Bson, Document, Uuid, doc}, +}; + +use crate::{ + db::{col, is_duplicate_key}, + error::{AppError, AppResult}, + models::{ + common::{new_id, new_meta}, + household::default_settings, + user::Role, + }, + services::seed, +}; + +pub struct NewAccount { + pub name: String, + pub email: String, + pub password_hash: String, + pub household_name: String, +} + +pub struct CreatedAccount { + pub user_id: Uuid, + pub household_id: Uuid, +} + +/// Kullanıcıyı, hanesini, örnek kategori ve ödeme türlerini **tek transaction** içinde oluşturur. +/// Herhangi bir adım başarısız olursa hiçbir kayıt oluşmaz. +pub async fn register(db: &Database, acc: NewAccount) -> AppResult { + let household_id = new_id(); + let user_id = new_id(); + + // Hanede household_id = _id (DATA_MODEL §3.2) + let mut household = new_meta(household_id); + household.insert("_id", household_id); + household.insert("name", acc.household_name); + household.insert("owner_id", user_id); + household.insert("settings", default_settings()); + + let mut user = new_meta(household_id); + user.insert("_id", user_id); + user.insert("email", acc.email); + user.insert("password_hash", acc.password_hash); + user.insert("name", acc.name); + user.insert("role", Role::Owner.as_str()); + user.insert("preferences", doc! { "theme": "system" }); + user.insert("last_login_at", Bson::Null); + + let mut session = db.client().start_session().await?; + session.start_transaction().await?; + + let result: mongodb::error::Result<()> = async { + let docs = |name| db.collection::(name); + docs(col::HOUSEHOLDS) + .insert_one(household) + .session(&mut session) + .await?; + docs(col::USERS) + .insert_one(user) + .session(&mut session) + .await?; + docs(col::CATEGORIES) + .insert_many(seed::categories(household_id)) + .session(&mut session) + .await?; + docs(col::PAYMENT_METHODS) + .insert_many(seed::payment_methods(household_id)) + .session(&mut session) + .await?; + Ok(()) + } + .await; + + match result { + Ok(()) => session.commit_transaction().await?, + Err(e) => { + let _ = session.abort_transaction().await; + return Err(if is_duplicate_key(&e) { + AppError::Conflict("Bu e-posta adresi zaten kayıtlı".into()) + } else { + e.into() + }); + } + } + + Ok(CreatedAccount { + user_id, + household_id, + }) +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs new file mode 100644 index 0000000..d1a05df --- /dev/null +++ b/backend/src/services/mod.rs @@ -0,0 +1,4 @@ +//! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar. + +pub mod account; +pub mod seed; diff --git a/backend/src/services/seed.rs b/backend/src/services/seed.rs new file mode 100644 index 0000000..26ab200 --- /dev/null +++ b/backend/src/services/seed.rs @@ -0,0 +1,75 @@ +//! Yeni hane için örnek alt kategoriler ve ödeme türleri (DATA_MODEL.md §3.5, §3.6). + +use mongodb::bson::{Document, Uuid}; + +use crate::models::common::new_meta; + +/// (grup, ad, emoji) +const CATEGORIES: &[(&str, &str, &str)] = &[ + ("needs", "Market", "🛒"), + ("needs", "Kira", "🏠"), + ("needs", "Faturalar", "💡"), + ("needs", "Ulaşım", "🚌"), + ("wants", "Dışarıda yemek", "🍽️"), + ("wants", "Giyim", "👕"), + ("wants", "Eğlence", "🎉"), + ("culture", "Kitap", "📖"), + ("culture", "Eğitim", "🎓"), + ("culture", "Sinema/Konser", "🎬"), + ("unexpected", "Sağlık", "💊"), + ("unexpected", "Tamir", "🔧"), + ("unexpected", "Hediye", "🎁"), +]; + +/// (ad, emoji) +const PAYMENT_METHODS: &[(&str, &str)] = &[ + ("Nakit", "💵"), + ("Kredi Kartı", "💳"), + ("Banka Kartı", "🏧"), + ("Havale/EFT", "🔁"), +]; + +pub fn categories(household_id: Uuid) -> Vec { + CATEGORIES + .iter() + .enumerate() + .map(|(i, (group, name, emoji))| { + let mut d = new_meta(household_id); + d.insert("group", *group); + d.insert("name", *name); + d.insert("emoji", *emoji); + d.insert("sort_order", (i as i32 + 1) * 10); + d.insert("is_active", true); + d + }) + .collect() +} + +pub fn payment_methods(household_id: Uuid) -> Vec { + PAYMENT_METHODS + .iter() + .enumerate() + .map(|(i, (name, emoji))| { + let mut d = new_meta(household_id); + d.insert("name", *name); + d.insert("emoji", *emoji); + d.insert("sort_order", (i as i32 + 1) * 10); + d.insert("is_active", true); + d + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::common::new_id; + + #[test] + fn every_group_has_categories() { + let docs = categories(new_id()); + for g in ["needs", "wants", "culture", "unexpected"] { + assert!(docs.iter().any(|d| d.get_str("group").unwrap() == g)); + } + } +} diff --git a/backend/src/state.rs b/backend/src/state.rs index af8bbdd..a87cd6e 100644 --- a/backend/src/state.rs +++ b/backend/src/state.rs @@ -4,12 +4,16 @@ use std::sync::Arc; use mongodb::Database; -use crate::config::Config; +use crate::{ + auth::{jwt::Jwt, limiter::LoginLimiter}, + config::Config, +}; /// `Clone` ucuzdur: `Database` ve `Arc` içte referans sayımı kullanır, veri kopyalanmaz. #[derive(Clone)] pub struct AppState { pub db: Database, - #[allow(dead_code)] // Faz 1 – Adım 3'te kullanılacak pub config: Arc, + pub jwt: Arc, + pub login_limiter: Arc, } diff --git a/docs/MEMORY.md b/docs/MEMORY.md index d3c7cbd..951f25c 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 1 – Adım 3:** Kimlik doğrulama: `POST /api/auth/register` (kullanıcı + hane + örnek kategori/ödeme türü, transaction), `login`, `logout`, `GET /api/auth/me`; argon2, JWT httpOnly cookie, giriş rate limit. +**Faz 1 – Adım 4:** Hane izolasyonu (AuthUser'ın tüm hane route'larında zorunlu kullanımı, `scope()`), üye davet kodu: `POST /api/household/invites` (yalnızca owner), `POST /api/auth/register` ile `invite_code` → mevcut haneye `member` olarak katılma; `GET /api/household` üye listesi. ## 📌 Alınan Kararlar - Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`. @@ -12,6 +12,9 @@ - Veri modeli v1 onaylandı (2026-11-11): ölçekli tam sayı (`_e6`), tarih `YYYY-MM-DD` metin, dönem bitişi hariç, kullanıcı = tek hane, ayarlar haneye gömülü, haftalık bütçe hesaplanır. - Model yaklaşımı: `#[serde(flatten)]` yerine ortak alanlar `models/common.rs` yardımcılarıyla (`new_meta`, `scope`, `touch`). Modeller ilgili adımda eklenir. - `households.household_id = _id` (scope filtresi her yerde aynı). +- Auth: JWT (HS256, jsonwebtoken `rust_crypto`) `kakeibo_session` cookie'de; HttpOnly, SameSite=Lax, Secure yalnızca APP_URL https ise. Beni hatırla → 30 gün Max-Age; değilse 12 saat JWT + oturum cookie'si. Kayıttan sonra 30 gün. +- Giriş sınırı: e-posta başına 15 dk'da 5 hatalı deneme (bellek içi, `auth/limiter.rs`). Olmayan kullanıcıda da sahte argon2 doğrulaması (zamanlama sızıntısı yok). +- Hata kodları: `{error:{code,message}}`; `invalid_credentials`, `unauthorized`, `bad_request`, `conflict`, `too_many_requests`. - Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7. ## ❓ Açık Konular @@ -32,3 +35,4 @@ ### 2026-11-11 — Faz 1 - **Adım 1:** `config.rs` (env + doğrulama, JWT_SECRET ≥ 32), `db.rs` (5 sn timeout + ping), `error.rs` (`AppError` → `{error:{code,message}}` Türkçe; 5xx detayı yalnızca logda), `state.rs`, `routes/` (health: DB yoksa 503). Açılışta DB/ayar hatasında anlaşılır log + çıkış kodu 1. Elle test: 4 senaryo geçti. - **Adım 2:** `db/` modülü: `col` sabitleri, `migrations.rs` (`schema_migrations`, v1 = koleksiyonları oluştur), `indexes.rs` (DATA_MODEL §4 + tüm hane koleksiyonlarına `(household_id, updated_at)`). `models/common.rs`: UUID v7, `new_meta`, `scope`, `touch`. Açılışta `db::prepare`. 4 birim testi; idempotency ve tek açık dönem indeksi elle doğrulandı. `uuid` (v7) eklendi. +- **Adım 3:** Kimlik doğrulama: `auth/` (password argon2id + spawn_blocking, jwt, limiter, session cookie + `AuthUser` extractor), `extract.rs` (`ValidJson` = JSON + validator, Türkçe 400), `services/account.rs` (kayıt tek transaction: hane + owner + 13 kategori + 4 ödeme türü), `services/seed.rs`, `routes/auth.rs` (register/login/logout/me). Yeni crate'ler: argon2, jsonwebtoken, axum-extra(cookie), validator, time. 11 birim testi; 13 senaryo curl ile, rollback DB'de doğrulandı. diff --git a/docs/PHASES.md b/docs/PHASES.md index 7d48778..5d6105b 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -27,6 +27,6 @@ ## Faz 1 Adımları - ✅ 1. `config.rs` + `db.rs` (MongoDB bağlantısı), Türkçe hata yapısı, health check'e DB durumu - ✅ 2. Modeller, migration mekanizması ve indeksler -- 🔄 3. Kimlik doğrulama: kayıt (+ hane + örnek kategori/ödeme türü), giriş, çıkış, `me`; argon2 + JWT cookie + rate limit -- ⏳ 4. Hane izolasyonu (middleware), üye davet kodu +- ✅ 3. Kimlik doğrulama: kayıt (+ hane + örnek kategori/ödeme türü), giriş, çıkış, `me`; argon2 + JWT cookie + rate limit +- 🔄 4. Hane izolasyonu (middleware), üye davet kodu - ⏳ 5. Kategori ve ödeme türü uç noktaları + hane izolasyonu testleri, `api.http`