Microservice to bring 2FA to self hosted PDSes

Compare changes

Choose any two refs to compare.

+4
.dockerignore
··· 1 + target 2 + /target 3 + **/.idea 4 + .idea
+194 -152
Cargo.lock
··· 112 112 checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 113 113 114 114 [[package]] 115 + name = "aws-lc-rs" 116 + version = "1.13.3" 117 + source = "registry+https://github.com/rust-lang/crates.io-index" 118 + checksum = "5c953fe1ba023e6b7730c0d4b031d06f267f23a46167dcbd40316644b10a17ba" 119 + dependencies = [ 120 + "aws-lc-sys", 121 + "untrusted 0.7.1", 122 + "zeroize", 123 + ] 124 + 125 + [[package]] 126 + name = "aws-lc-sys" 127 + version = "0.30.0" 128 + source = "registry+https://github.com/rust-lang/crates.io-index" 129 + checksum = "dbfd150b5dbdb988bcc8fb1fe787eb6b7ee6180ca24da683b61ea5405f3d43ff" 130 + dependencies = [ 131 + "bindgen", 132 + "cc", 133 + "cmake", 134 + "dunce", 135 + "fs_extra", 136 + ] 137 + 138 + [[package]] 115 139 name = "axum" 116 140 version = "0.8.4" 117 141 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 217 241 checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" 218 242 219 243 [[package]] 244 + name = "bindgen" 245 + version = "0.69.5" 246 + source = "registry+https://github.com/rust-lang/crates.io-index" 247 + checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" 248 + dependencies = [ 249 + "bitflags", 250 + "cexpr", 251 + "clang-sys", 252 + "itertools", 253 + "lazy_static", 254 + "lazycell", 255 + "log", 256 + "prettyplease", 257 + "proc-macro2", 258 + "quote", 259 + "regex", 260 + "rustc-hash", 261 + "shlex", 262 + "syn", 263 + "which", 264 + ] 265 + 266 + [[package]] 220 267 name = "bitflags" 221 268 version = "2.9.1" 222 269 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 274 321 ] 275 322 276 323 [[package]] 324 + name = "cexpr" 325 + version = "0.6.0" 326 + source = "registry+https://github.com/rust-lang/crates.io-index" 327 + checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" 328 + dependencies = [ 329 + "nom 7.1.3", 330 + ] 331 + 332 + [[package]] 277 333 name = "cfg-if" 278 334 version = "1.0.1" 279 335 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 287 343 dependencies = [ 288 344 "android-tzdata", 289 345 "iana-time-zone", 346 + "js-sys", 290 347 "num-traits", 348 + "wasm-bindgen", 291 349 "windows-link", 292 350 ] 293 351 ··· 339 397 ] 340 398 341 399 [[package]] 400 + name = "clang-sys" 401 + version = "1.8.1" 402 + source = "registry+https://github.com/rust-lang/crates.io-index" 403 + checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" 404 + dependencies = [ 405 + "glob", 406 + "libc", 407 + "libloading", 408 + ] 409 + 410 + [[package]] 411 + name = "cmake" 412 + version = "0.1.54" 413 + source = "registry+https://github.com/rust-lang/crates.io-index" 414 + checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" 415 + dependencies = [ 416 + "cc", 417 + ] 418 + 419 + [[package]] 342 420 name = "concurrent-queue" 343 421 version = "2.5.0" 344 422 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 354 432 checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" 355 433 356 434 [[package]] 357 - name = "core-foundation" 358 - version = "0.9.4" 359 - source = "registry+https://github.com/rust-lang/crates.io-index" 360 - checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 361 - dependencies = [ 362 - "core-foundation-sys", 363 - "libc", 364 - ] 365 - 366 - [[package]] 367 435 name = "core-foundation-sys" 368 436 version = "0.8.7" 369 437 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 545 613 checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" 546 614 547 615 [[package]] 616 + name = "dunce" 617 + version = "1.0.5" 618 + source = "registry+https://github.com/rust-lang/crates.io-index" 619 + checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" 620 + 621 + [[package]] 548 622 name = "either" 549 623 version = "1.15.0" 550 624 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 635 709 version = "0.1.5" 636 710 source = "registry+https://github.com/rust-lang/crates.io-index" 637 711 checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 638 - 639 - [[package]] 640 - name = "foreign-types" 641 - version = "0.3.2" 642 - source = "registry+https://github.com/rust-lang/crates.io-index" 643 - checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" 644 - dependencies = [ 645 - "foreign-types-shared", 646 - ] 647 - 648 - [[package]] 649 - name = "foreign-types-shared" 650 - version = "0.1.1" 651 - source = "registry+https://github.com/rust-lang/crates.io-index" 652 - checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" 653 712 654 713 [[package]] 655 714 name = "form_urlencoded" ··· 671 730 ] 672 731 673 732 [[package]] 733 + name = "fs_extra" 734 + version = "1.3.0" 735 + source = "registry+https://github.com/rust-lang/crates.io-index" 736 + checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" 737 + 738 + [[package]] 674 739 name = "futures-channel" 675 740 version = "0.3.31" 676 741 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 788 853 version = "0.31.1" 789 854 source = "registry+https://github.com/rust-lang/crates.io-index" 790 855 checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 856 + 857 + [[package]] 858 + name = "glob" 859 + version = "0.3.3" 860 + source = "registry+https://github.com/rust-lang/crates.io-index" 861 + checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" 791 862 792 863 [[package]] 793 864 name = "globset" ··· 941 1012 ] 942 1013 943 1014 [[package]] 944 - name = "hostname" 945 - version = "0.4.1" 946 - source = "registry+https://github.com/rust-lang/crates.io-index" 947 - checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" 948 - dependencies = [ 949 - "cfg-if", 950 - "libc", 951 - "windows-link", 952 - ] 953 - 954 - [[package]] 955 1015 name = "http" 956 1016 version = "1.3.1" 957 1017 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1220 1280 ] 1221 1281 1222 1282 [[package]] 1283 + name = "itertools" 1284 + version = "0.12.1" 1285 + source = "registry+https://github.com/rust-lang/crates.io-index" 1286 + checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" 1287 + dependencies = [ 1288 + "either", 1289 + ] 1290 + 1291 + [[package]] 1223 1292 name = "itoa" 1224 1293 version = "1.0.15" 1225 1294 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1277 1346 ] 1278 1347 1279 1348 [[package]] 1349 + name = "lazycell" 1350 + version = "1.3.0" 1351 + source = "registry+https://github.com/rust-lang/crates.io-index" 1352 + checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" 1353 + 1354 + [[package]] 1280 1355 name = "lettre" 1281 1356 version = "0.11.18" 1282 1357 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1290 1365 "fastrand", 1291 1366 "futures-io", 1292 1367 "futures-util", 1293 - "hostname", 1294 1368 "httpdate", 1295 1369 "idna", 1296 1370 "mime", 1297 - "native-tls", 1298 - "nom", 1371 + "nom 8.0.0", 1299 1372 "percent-encoding", 1300 1373 "quoted_printable", 1374 + "rustls", 1301 1375 "socket2", 1302 1376 "tokio", 1303 - "tokio-native-tls", 1377 + "tokio-rustls", 1304 1378 "url", 1379 + "webpki-roots 1.0.2", 1305 1380 ] 1306 1381 1307 1382 [[package]] ··· 1309 1384 version = "0.2.175" 1310 1385 source = "registry+https://github.com/rust-lang/crates.io-index" 1311 1386 checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" 1387 + 1388 + [[package]] 1389 + name = "libloading" 1390 + version = "0.8.8" 1391 + source = "registry+https://github.com/rust-lang/crates.io-index" 1392 + checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" 1393 + dependencies = [ 1394 + "cfg-if", 1395 + "windows-targets 0.52.6", 1396 + ] 1312 1397 1313 1398 [[package]] 1314 1399 name = "libm" ··· 1340 1425 1341 1426 [[package]] 1342 1427 name = "linux-raw-sys" 1343 - version = "0.9.4" 1428 + version = "0.4.15" 1344 1429 source = "registry+https://github.com/rust-lang/crates.io-index" 1345 - checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" 1430 + checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 1346 1431 1347 1432 [[package]] 1348 1433 name = "litemap" ··· 1404 1489 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 1405 1490 1406 1491 [[package]] 1492 + name = "minimal-lexical" 1493 + version = "0.2.1" 1494 + source = "registry+https://github.com/rust-lang/crates.io-index" 1495 + checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" 1496 + 1497 + [[package]] 1407 1498 name = "miniz_oxide" 1408 1499 version = "0.8.9" 1409 1500 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1424 1515 ] 1425 1516 1426 1517 [[package]] 1427 - name = "native-tls" 1428 - version = "0.2.14" 1518 + name = "nom" 1519 + version = "7.1.3" 1429 1520 source = "registry+https://github.com/rust-lang/crates.io-index" 1430 - checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" 1521 + checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" 1431 1522 dependencies = [ 1432 - "libc", 1433 - "log", 1434 - "openssl", 1435 - "openssl-probe", 1436 - "openssl-sys", 1437 - "schannel", 1438 - "security-framework", 1439 - "security-framework-sys", 1440 - "tempfile", 1523 + "memchr", 1524 + "minimal-lexical", 1441 1525 ] 1442 1526 1443 1527 [[package]] ··· 1549 1633 checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 1550 1634 1551 1635 [[package]] 1552 - name = "openssl" 1553 - version = "0.10.73" 1554 - source = "registry+https://github.com/rust-lang/crates.io-index" 1555 - checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" 1556 - dependencies = [ 1557 - "bitflags", 1558 - "cfg-if", 1559 - "foreign-types", 1560 - "libc", 1561 - "once_cell", 1562 - "openssl-macros", 1563 - "openssl-sys", 1564 - ] 1565 - 1566 - [[package]] 1567 - name = "openssl-macros" 1568 - version = "0.1.1" 1569 - source = "registry+https://github.com/rust-lang/crates.io-index" 1570 - checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" 1571 - dependencies = [ 1572 - "proc-macro2", 1573 - "quote", 1574 - "syn", 1575 - ] 1576 - 1577 - [[package]] 1578 - name = "openssl-probe" 1579 - version = "0.1.6" 1580 - source = "registry+https://github.com/rust-lang/crates.io-index" 1581 - checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 1582 - 1583 - [[package]] 1584 - name = "openssl-sys" 1585 - version = "0.9.109" 1586 - source = "registry+https://github.com/rust-lang/crates.io-index" 1587 - checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" 1588 - dependencies = [ 1589 - "cc", 1590 - "libc", 1591 - "pkg-config", 1592 - "vcpkg", 1593 - ] 1594 - 1595 - [[package]] 1596 1636 name = "overload" 1597 1637 version = "0.1.1" 1598 1638 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1650 1690 1651 1691 [[package]] 1652 1692 name = "pds_gatekeeper" 1653 - version = "0.1.0" 1693 + version = "0.1.2" 1654 1694 dependencies = [ 1695 + "anyhow", 1696 + "aws-lc-rs", 1655 1697 "axum", 1656 1698 "axum-template", 1699 + "chrono", 1657 1700 "dotenvy", 1658 1701 "handlebars", 1659 1702 "hex", 1660 1703 "hyper-util", 1661 1704 "jwt-compact", 1662 1705 "lettre", 1706 + "rand 0.9.2", 1663 1707 "rust-embed", 1708 + "rustls", 1664 1709 "scrypt", 1665 1710 "serde", 1666 1711 "serde_json", 1712 + "sha2", 1667 1713 "sqlx", 1668 1714 "tokio", 1669 1715 "tower-http", ··· 1815 1861 ] 1816 1862 1817 1863 [[package]] 1864 + name = "prettyplease" 1865 + version = "0.2.35" 1866 + source = "registry+https://github.com/rust-lang/crates.io-index" 1867 + checksum = "061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a" 1868 + dependencies = [ 1869 + "proc-macro2", 1870 + "syn", 1871 + ] 1872 + 1873 + [[package]] 1818 1874 name = "proc-macro2" 1819 1875 version = "1.0.97" 1820 1876 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1999 2055 "cfg-if", 2000 2056 "getrandom 0.2.16", 2001 2057 "libc", 2002 - "untrusted", 2058 + "untrusted 0.9.0", 2003 2059 "windows-sys 0.52.0", 2004 2060 ] 2005 2061 ··· 2065 2121 checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" 2066 2122 2067 2123 [[package]] 2124 + name = "rustc-hash" 2125 + version = "1.1.0" 2126 + source = "registry+https://github.com/rust-lang/crates.io-index" 2127 + checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" 2128 + 2129 + [[package]] 2068 2130 name = "rustix" 2069 - version = "1.0.8" 2131 + version = "0.38.44" 2070 2132 source = "registry+https://github.com/rust-lang/crates.io-index" 2071 - checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" 2133 + checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 2072 2134 dependencies = [ 2073 2135 "bitflags", 2074 2136 "errno", ··· 2083 2145 source = "registry+https://github.com/rust-lang/crates.io-index" 2084 2146 checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" 2085 2147 dependencies = [ 2148 + "aws-lc-rs", 2149 + "log", 2086 2150 "once_cell", 2087 2151 "ring", 2088 2152 "rustls-pki-types", ··· 2106 2170 source = "registry+https://github.com/rust-lang/crates.io-index" 2107 2171 checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" 2108 2172 dependencies = [ 2173 + "aws-lc-rs", 2109 2174 "ring", 2110 2175 "rustls-pki-types", 2111 - "untrusted", 2176 + "untrusted 0.9.0", 2112 2177 ] 2113 2178 2114 2179 [[package]] ··· 2142 2207 ] 2143 2208 2144 2209 [[package]] 2145 - name = "schannel" 2146 - version = "0.1.27" 2147 - source = "registry+https://github.com/rust-lang/crates.io-index" 2148 - checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" 2149 - dependencies = [ 2150 - "windows-sys 0.59.0", 2151 - ] 2152 - 2153 - [[package]] 2154 2210 name = "scopeguard" 2155 2211 version = "1.2.0" 2156 2212 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2184 2240 checksum = "e5d1746aae42c19d583c3c1a8c646bfad910498e2051c551a7f2e3c0c9fbb7eb" 2185 2241 dependencies = [ 2186 2242 "cc", 2187 - ] 2188 - 2189 - [[package]] 2190 - name = "security-framework" 2191 - version = "2.11.1" 2192 - source = "registry+https://github.com/rust-lang/crates.io-index" 2193 - checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" 2194 - dependencies = [ 2195 - "bitflags", 2196 - "core-foundation", 2197 - "core-foundation-sys", 2198 - "libc", 2199 - "security-framework-sys", 2200 - ] 2201 - 2202 - [[package]] 2203 - name = "security-framework-sys" 2204 - version = "2.14.0" 2205 - source = "registry+https://github.com/rust-lang/crates.io-index" 2206 - checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" 2207 - dependencies = [ 2208 - "core-foundation-sys", 2209 - "libc", 2210 2243 ] 2211 2244 2212 2245 [[package]] ··· 2393 2426 dependencies = [ 2394 2427 "base64", 2395 2428 "bytes", 2429 + "chrono", 2396 2430 "crc", 2397 2431 "crossbeam-queue", 2398 2432 "either", ··· 2470 2504 "bitflags", 2471 2505 "byteorder", 2472 2506 "bytes", 2507 + "chrono", 2473 2508 "crc", 2474 2509 "digest", 2475 2510 "dotenvy", ··· 2511 2546 "base64", 2512 2547 "bitflags", 2513 2548 "byteorder", 2549 + "chrono", 2514 2550 "crc", 2515 2551 "dotenvy", 2516 2552 "etcetera", ··· 2545 2581 checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" 2546 2582 dependencies = [ 2547 2583 "atoi", 2584 + "chrono", 2548 2585 "flume", 2549 2586 "futures-channel", 2550 2587 "futures-core", ··· 2633 2670 ] 2634 2671 2635 2672 [[package]] 2636 - name = "tempfile" 2637 - version = "3.21.0" 2638 - source = "registry+https://github.com/rust-lang/crates.io-index" 2639 - checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" 2640 - dependencies = [ 2641 - "fastrand", 2642 - "getrandom 0.3.3", 2643 - "once_cell", 2644 - "rustix", 2645 - "windows-sys 0.59.0", 2646 - ] 2647 - 2648 - [[package]] 2649 2673 name = "thiserror" 2650 2674 version = "1.0.69" 2651 2675 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2750 2774 ] 2751 2775 2752 2776 [[package]] 2753 - name = "tokio-native-tls" 2754 - version = "0.3.1" 2777 + name = "tokio-rustls" 2778 + version = "0.26.2" 2755 2779 source = "registry+https://github.com/rust-lang/crates.io-index" 2756 - checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" 2780 + checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" 2757 2781 dependencies = [ 2758 - "native-tls", 2782 + "rustls", 2759 2783 "tokio", 2760 2784 ] 2761 2785 ··· 2988 3012 2989 3013 [[package]] 2990 3014 name = "untrusted" 3015 + version = "0.7.1" 3016 + source = "registry+https://github.com/rust-lang/crates.io-index" 3017 + checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" 3018 + 3019 + [[package]] 3020 + name = "untrusted" 2991 3021 version = "0.9.0" 2992 3022 source = "registry+https://github.com/rust-lang/crates.io-index" 2993 3023 checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" ··· 3161 3191 checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" 3162 3192 dependencies = [ 3163 3193 "rustls-pki-types", 3194 + ] 3195 + 3196 + [[package]] 3197 + name = "which" 3198 + version = "4.4.2" 3199 + source = "registry+https://github.com/rust-lang/crates.io-index" 3200 + checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" 3201 + dependencies = [ 3202 + "either", 3203 + "home", 3204 + "once_cell", 3205 + "rustix", 3164 3206 ] 3165 3207 3166 3208 [[package]]
+12 -4
Cargo.toml
··· 1 1 [package] 2 2 name = "pds_gatekeeper" 3 - version = "0.1.0" 3 + version = "0.1.2" 4 4 edition = "2024" 5 + license = "MIT" 5 6 6 7 [dependencies] 7 8 axum = { version = "0.8.4", features = ["macros", "json"] } 8 9 tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros", "signal"] } 9 - sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "migrate"] } 10 + sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "migrate", "chrono"] } 10 11 dotenvy = "0.15.7" 11 12 serde = { version = "1.0", features = ["derive"] } 12 13 serde_json = "1.0" ··· 14 15 tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } 15 16 hyper-util = { version = "0.1.16", features = ["client", "client-legacy"] } 16 17 tower-http = { version = "0.6", features = ["cors", "compression-zstd"] } 17 - tower_governor = "0.8.0" 18 + tower_governor = { version = "0.8.0", features = ["axum", "tracing"] } 18 19 hex = "0.4" 19 20 jwt-compact = { version = "0.8.0", features = ["es256k"] } 20 21 scrypt = "0.11" 21 - lettre = { version = "0.11.18", features = ["tokio1", "pool", "tokio1-native-tls"] } 22 + #Leaveing these two cause I think it is needed by the email crate for ssl 23 + aws-lc-rs = "1.13.0" 24 + rustls = { version = "0.23", default-features = false, features = ["tls12", "std", "logging", "aws_lc_rs"] } 25 + lettre = { version = "0.11", default-features = false, features = ["builder", "webpki-roots", "rustls", "aws-lc-rs", "smtp-transport", "tokio1", "tokio1-rustls"] } 22 26 handlebars = { version = "6.3.2", features = ["rust-embed"] } 23 27 rust-embed = "8.7.2" 24 28 axum-template = { version = "3.0.0", features = ["handlebars"] } 29 + rand = "0.9.2" 30 + anyhow = "1.0.99" 31 + chrono = "0.4.41" 32 + sha2 = "0.10"
+10
Dockerfile
··· 1 + FROM rust:1.89.0-bookworm AS builder 2 + WORKDIR /app 3 + COPY ../ /app 4 + RUN cargo build --release 5 + # 6 + FROM rust:1.89-slim-bookworm AS api 7 + RUN apt-get update 8 + RUN apt-get install -y ca-certificates 9 + COPY --from=builder /app/target/release/pds_gatekeeper /usr/local/bin/pds_gatekeeper 10 + CMD ["pds_gatekeeper"]
+21
LICENSE.md
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 Bailey Townsend 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+139 -17
README.md
··· 12 12 13 13 ## 2FA 14 14 15 - - [x] Ability to turn on/off 2FA 16 - - [x] getSession overwrite to set the `emailAuthFactor` flag if the user has 2FA turned on 17 - - [x] send an email using the `PDS_EMAIL_SMTP_URL` with a handlebar email template like Bluesky's 2FA sign in email. 18 - - [ ] generate a 2FA code 19 - - [ ] createSession gatekeeping (It does stop logins, just eh, doesn't actually send a real code or check it yet) 20 - - [ ] oauth endpoint gatekeeping 15 + - Overrides The login endpoint to add 2FA for both Bluesky client logged in and OAuth logins 16 + - Overrides the settings endpoints as well. As long as you have a confirmed email you can turn on 2FA 21 17 22 18 ## Captcha on Create Account 23 19 ··· 25 21 26 22 # Setup 27 23 28 - Nothing here yet! If you are brave enough to try before full release, let me know and I'll help you set it up. 29 - But I want to run it locally on my own PDS first to test run it a bit. 24 + PDS Gatekeeper has 2 parts to its setup, docker compose file and a reverse proxy (Caddy in this case). I will be 25 + assuming you setup the PDS following the directions 26 + found [here](https://atproto.com/guides/self-hosting), but if yours is different, or you have questions, feel free to 27 + let 28 + me know, and we can figure it out. 30 29 31 - Example Caddyfile (mostly so I don't lose it for now. Will have a better one in the future) 30 + ## Docker compose 31 + 32 + The pds gatekeeper container can be found on docker hub under the name `fatfingers23/pds_gatekeeper`. The container does 33 + need access to the `/pds` root folder to access the same db's as your PDS. The part you need to add would look a bit 34 + like below. You can find a full example of what I use for my pds at [./examples/compose.yml](./examples/compose.yml). 35 + This is usually found at `/pds/compose.yaml`on your PDS> 32 36 33 - ```caddyfile 34 - http://localhost { 37 + ```yml 38 + gatekeeper: 39 + container_name: gatekeeper 40 + image: fatfingers23/pds_gatekeeper:latest 41 + network_mode: host 42 + restart: unless-stopped 43 + #This gives the container to the access to the PDS folder. Source is the location on your server of that directory 44 + volumes: 45 + - type: bind 46 + source: /pds 47 + target: /pds 48 + depends_on: 49 + - pds 50 + ``` 35 51 52 + For Coolify, if you're using Traefik as your proxy you'll need to make sure the labels for the container are set up correctly. A full example can be found at [./examples/coolify-compose.yml](./examples/coolify-compose.yml). 53 + 54 + ```yml 55 + gatekeeper: 56 + container_name: gatekeeper 57 + image: 'fatfingers23/pds_gatekeeper:latest' 58 + restart: unless-stopped 59 + volumes: 60 + - '/pds:/pds' 61 + environment: 62 + - 'PDS_DATA_DIRECTORY=${PDS_DATA_DIRECTORY:-/pds}' 63 + - 'PDS_BASE_URL=http://pds:3000' 64 + - GATEKEEPER_HOST=0.0.0.0 65 + depends_on: 66 + - pds 67 + healthcheck: 68 + test: 69 + - CMD 70 + - timeout 71 + - '1' 72 + - bash 73 + - '-c' 74 + - 'cat < /dev/null > /dev/tcp/0.0.0.0/8080' 75 + interval: 10s 76 + timeout: 5s 77 + retries: 3 78 + start_period: 10s 79 + labels: 80 + - traefik.enable=true 81 + - 'traefik.http.routers.pds-gatekeeper.rule=Host(`yourpds.com`) && (Path(`/xrpc/com.atproto.server.getSession`) || Path(`/xrpc/com.atproto.server.updateEmail`) || Path(`/xrpc/com.atproto.server.createSession`) || Path(`/xrpc/com.atproto.server.createAccount`) || Path(`/@atproto/oauth-provider/~api/sign-in`))' 82 + - traefik.http.routers.pds-gatekeeper.entrypoints=https 83 + - traefik.http.routers.pds-gatekeeper.tls=true 84 + - traefik.http.routers.pds-gatekeeper.priority=100 85 + - traefik.http.routers.pds-gatekeeper.middlewares=gatekeeper-cors 86 + - traefik.http.services.pds-gatekeeper.loadbalancer.server.port=8080 87 + - traefik.http.services.pds-gatekeeper.loadbalancer.server.scheme=http 88 + - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE,OPTIONS,PATCH' 89 + - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowheaders=*' 90 + - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolalloworiginlist=*' 91 + - traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolmaxage=100 92 + - traefik.http.middlewares.gatekeeper-cors.headers.addvaryheader=true 93 + - traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowcredentials=true 94 + ``` 95 + 96 + ## Caddy setup 97 + 98 + For the reverse proxy I use caddy. This part is what overwrites the endpoints and proxies them to PDS gatekeeper to add 99 + in extra functionality. The main part is below, for a full example see [./examples/Caddyfile](./examples/Caddyfile). 100 + This is usually found at `/pds/caddy/etc/caddy/Caddyfile` on your PDS. 101 + 102 + ```caddyfile 36 103 @gatekeeper { 37 - path /xrpc/com.atproto.server.getSession 38 - path /xrpc/com.atproto.server.updateEmail 39 - path /xrpc/com.atproto.server.createSession 104 + path /xrpc/com.atproto.server.getSession 105 + path /xrpc/com.atproto.server.updateEmail 106 + path /xrpc/com.atproto.server.createSession 107 + path /xrpc/com.atproto.server.createAccount 108 + path /@atproto/oauth-provider/~api/sign-in 40 109 } 41 110 42 111 handle @gatekeeper { 43 - reverse_proxy http://localhost:8080 112 + reverse_proxy http://localhost:8080 44 113 } 45 114 46 - reverse_proxy /* http://localhost:3000 115 + reverse_proxy http://localhost:3000 116 + ``` 117 + 118 + If you use a cloudflare tunnel then your caddyfile would look a bit more like below with your tunnel proxying to 119 + `localhost:8081` (or w/e port you want). 120 + 121 + ```caddyfile 122 + http://*.localhost:8082, http://localhost:8082 { 123 + @gatekeeper { 124 + path /xrpc/com.atproto.server.getSession 125 + path /xrpc/com.atproto.server.updateEmail 126 + path /xrpc/com.atproto.server.createSession 127 + path /xrpc/com.atproto.server.createAccount 128 + path /@atproto/oauth-provider/~api/sign-in 129 + } 130 + 131 + handle @gatekeeper { 132 + reverse_proxy http://localhost:8080 { 133 + #Makes sure the cloudflare ip is proxied and able to be picked up by pds gatekeeper 134 + header_up X-Forwarded-For {http.request.header.CF-Connecting-IP} 135 + } 136 + } 137 + 138 + reverse_proxy http://localhost:3000 47 139 } 48 140 49 - ``` 141 + ``` 142 + 143 + # Environment variables and bonuses 144 + 145 + Every environment variable can be set in the `pds.env` and shared between PDS and gatekeeper and the PDS, with the 146 + exception of `PDS_ENV_LOCATION`. This can be set to load the pds.env, by default it checks `/pds/pds.env` and is 147 + recommended to mount the `/pds` folder on the server to `/pds` in the pds gatekeeper container. 148 + 149 + `PDS_DATA_DIRECTORY` - Root directory of the PDS. Same as the one found in `pds.env` this is how pds gatekeeper knows 150 + knows the rest of the environment variables. 151 + 152 + `GATEKEEPER_EMAIL_TEMPLATES_DIRECTORY` - The folder for templates of the emails PDS gatekeeper sends. You can find them 153 + in [./email_templates](./email_templates). You are free to edit them as you please and set this variable to a location 154 + in the pds gateekeper container and it will use them in place of the default ones. Just make sure ot keep the names the 155 + same. 156 + 157 + `GATEKEEPER_TWO_FACTOR_EMAIL_SUBJECT` - Subject of the email sent to the user when they turn on 2FA. Defaults to 158 + `Sign in to Bluesky` 159 + 160 + `PDS_BASE_URL` - Base url of the PDS. You most likely want `https://localhost:3000` which is also the default 161 + 162 + `GATEKEEPER_HOST` - Host for pds gatekeeper. Defaults to `127.0.0.1` 163 + 164 + `GATEKEEPER_PORT` - Port for pds gatekeeper. Defaults to `8080` 165 + 166 + `GATEKEEPER_CREATE_ACCOUNT_PER_SECOND` - Sets how often it takes a count off the limiter. example if you hit the rate 167 + limit of 5 and set to 60, then in 60 seconds you will be able to make one more. Or in 5 minutes be able to make 5 more. 168 + 169 + `GATEKEEPER_CREATE_ACCOUNT_BURST` - Sets how many requests can be made in a burst. In the prior example this is where 170 + the 5 comes from. Example can set this to 10 to allow for 10 requests in a burst, and after 60 seconds it will drop one 171 + off.
+30
examples/Caddyfile
··· 1 + { 2 + email youremail@myemail.com 3 + on_demand_tls { 4 + ask http://localhost:3000/tls-check 5 + } 6 + } 7 + 8 + *.yourpds.com, yourpds.com { 9 + tls { 10 + on_demand 11 + } 12 + # You'll most likely just want from here to.... 13 + @gatekeeper { 14 + path /xrpc/com.atproto.server.getSession 15 + path /xrpc/com.atproto.server.updateEmail 16 + path /xrpc/com.atproto.server.createSession 17 + path /xrpc/com.atproto.server.createAccount 18 + path /@atproto/oauth-provider/~api/sign-in 19 + } 20 + 21 + handle @gatekeeper { 22 + #This is the address for PDS gatekeeper, default is 8080 23 + reverse_proxy http://localhost:8080 24 + } 25 + 26 + reverse_proxy http://localhost:3000 27 + #..here. Copy and paste this replacing the reverse_proxy http://localhost:3000 line 28 + } 29 + 30 +
+51
examples/compose.yml
··· 1 + version: '3.9' 2 + services: 3 + caddy: 4 + container_name: caddy 5 + image: caddy:2 6 + network_mode: host 7 + depends_on: 8 + - pds 9 + restart: unless-stopped 10 + volumes: 11 + - type: bind 12 + source: /pds/caddy/data 13 + target: /data 14 + - type: bind 15 + source: /pds/caddy/etc/caddy 16 + target: /etc/caddy 17 + pds: 18 + container_name: pds 19 + image: ghcr.io/bluesky-social/pds:0.4 20 + network_mode: host 21 + restart: unless-stopped 22 + volumes: 23 + - type: bind 24 + source: /pds 25 + target: /pds 26 + env_file: 27 + - /pds/pds.env 28 + watchtower: 29 + container_name: watchtower 30 + image: containrrr/watchtower:latest 31 + network_mode: host 32 + volumes: 33 + - type: bind 34 + source: /var/run/docker.sock 35 + target: /var/run/docker.sock 36 + restart: unless-stopped 37 + environment: 38 + WATCHTOWER_CLEANUP: true 39 + WATCHTOWER_SCHEDULE: "@midnight" 40 + gatekeeper: 41 + container_name: gatekeeper 42 + image: fatfingers23/pds_gatekeeper:latest 43 + network_mode: host 44 + restart: unless-stopped 45 + #This gives the container to the access to the PDS folder. Source is the location on your server of that directory 46 + volumes: 47 + - type: bind 48 + source: /pds 49 + target: /pds 50 + depends_on: 51 + - pds
+73
examples/coolify-compose.yml
··· 1 + services: 2 + pds: 3 + image: 'ghcr.io/bluesky-social/pds:0.4.182' 4 + volumes: 5 + - '/pds:/pds' 6 + environment: 7 + - SERVICE_URL_PDS_3000 8 + - 'PDS_HOSTNAME=${SERVICE_FQDN_PDS_3000}' 9 + - 'PDS_JWT_SECRET=${SERVICE_HEX_32_JWTSECRET}' 10 + - 'PDS_ADMIN_PASSWORD=${SERVICE_PASSWORD_ADMIN}' 11 + - 'PDS_ADMIN_EMAIL=${PDS_ADMIN_EMAIL}' 12 + - 'PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=${SERVICE_HEX_32_ROTATIONKEY}' 13 + - 'PDS_DATA_DIRECTORY=${PDS_DATA_DIRECTORY:-/pds}' 14 + - 'PDS_BLOBSTORE_DISK_LOCATION=${PDS_DATA_DIRECTORY:-/pds}/blocks' 15 + - 'PDS_BLOB_UPLOAD_LIMIT=${PDS_BLOB_UPLOAD_LIMIT:-104857600}' 16 + - 'PDS_DID_PLC_URL=${PDS_DID_PLC_URL:-https://plc.directory}' 17 + - 'PDS_EMAIL_FROM_ADDRESS=${PDS_EMAIL_FROM_ADDRESS}' 18 + - 'PDS_EMAIL_SMTP_URL=${PDS_EMAIL_SMTP_URL}' 19 + - 'PDS_BSKY_APP_VIEW_URL=${PDS_BSKY_APP_VIEW_URL:-https://api.bsky.app}' 20 + - 'PDS_BSKY_APP_VIEW_DID=${PDS_BSKY_APP_VIEW_DID:-did:web:api.bsky.app}' 21 + - 'PDS_REPORT_SERVICE_URL=${PDS_REPORT_SERVICE_URL:-https://mod.bsky.app/xrpc/com.atproto.moderation.createReport}' 22 + - 'PDS_REPORT_SERVICE_DID=${PDS_REPORT_SERVICE_DID:-did:plc:ar7c4by46qjdydhdevvrndac}' 23 + - 'PDS_CRAWLERS=${PDS_CRAWLERS:-https://bsky.network}' 24 + - 'LOG_ENABLED=${LOG_ENABLED:-true}' 25 + command: "sh -c '\n set -euo pipefail\n echo \"Installing required packages and pdsadmin...\"\n apk add --no-cache openssl curl bash jq coreutils gnupg util-linux-misc >/dev/null\n curl -o /usr/local/bin/pdsadmin.sh https://raw.githubusercontent.com/bluesky-social/pds/main/pdsadmin.sh\n chmod 700 /usr/local/bin/pdsadmin.sh\n ln -sf /usr/local/bin/pdsadmin.sh /usr/local/bin/pdsadmin\n echo \"Creating an empty pds.env file so pdsadmin works...\"\n touch ${PDS_DATA_DIRECTORY}/pds.env\n echo \"Launching PDS, enjoy!...\"\n exec node --enable-source-maps index.js\n'\n" 26 + healthcheck: 27 + test: 28 + - CMD 29 + - wget 30 + - '--spider' 31 + - 'http://127.0.0.1:3000/xrpc/_health' 32 + interval: 5s 33 + timeout: 10s 34 + retries: 10 35 + gatekeeper: 36 + container_name: gatekeeper 37 + image: 'fatfingers23/pds_gatekeeper:latest' 38 + restart: unless-stopped 39 + volumes: 40 + - '/pds:/pds' 41 + environment: 42 + - 'PDS_DATA_DIRECTORY=${PDS_DATA_DIRECTORY:-/pds}' 43 + - 'PDS_BASE_URL=http://pds:3000' 44 + - GATEKEEPER_HOST=0.0.0.0 45 + depends_on: 46 + - pds 47 + healthcheck: 48 + test: 49 + - CMD 50 + - timeout 51 + - '1' 52 + - bash 53 + - '-c' 54 + - 'cat < /dev/null > /dev/tcp/0.0.0.0/8080' 55 + interval: 10s 56 + timeout: 5s 57 + retries: 3 58 + start_period: 10s 59 + labels: 60 + - traefik.enable=true 61 + - 'traefik.http.routers.pds-gatekeeper.rule=Host(`yourpds.com`) && (Path(`/xrpc/com.atproto.server.getSession`) || Path(`/xrpc/com.atproto.server.updateEmail`) || Path(`/xrpc/com.atproto.server.createSession`) || Path(`/xrpc/com.atproto.server.createAccount`) || Path(`/@atproto/oauth-provider/~api/sign-in`))' 62 + - traefik.http.routers.pds-gatekeeper.entrypoints=https 63 + - traefik.http.routers.pds-gatekeeper.tls=true 64 + - traefik.http.routers.pds-gatekeeper.priority=100 65 + - traefik.http.routers.pds-gatekeeper.middlewares=gatekeeper-cors 66 + - traefik.http.services.pds-gatekeeper.loadbalancer.server.port=8080 67 + - traefik.http.services.pds-gatekeeper.loadbalancer.server.scheme=http 68 + - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE,OPTIONS,PATCH' 69 + - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowheaders=*' 70 + - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolalloworiginlist=*' 71 + - traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolmaxage=100 72 + - traefik.http.middlewares.gatekeeper-cors.headers.addvaryheader=true 73 + - traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowcredentials=true
+6
justfile
··· 1 + release: 2 + docker buildx build \ 3 + --platform linux/arm64,linux/amd64 \ 4 + --tag fatfingers23/pds_gatekeeper:latest \ 5 + --tag fatfingers23/pds_gatekeeper:0.1.0.3 \ 6 + --push .
-3
migrations_bells_and_whistles/.keep
··· 1 - # This directory holds SQLx migrations for the bells_and_whistles.sqlite database. 2 - # It is intentionally empty for now; running `sqlx::migrate!` will still ensure the 3 - # migrations table exists and succeed with zero migrations.
+525
src/helpers.rs
··· 1 + use crate::AppState; 2 + use crate::helpers::TokenCheckError::InvalidToken; 3 + use anyhow::anyhow; 4 + use axum::body::{Body, to_bytes}; 5 + use axum::extract::Request; 6 + use axum::http::header::CONTENT_TYPE; 7 + use axum::http::{HeaderMap, StatusCode, Uri}; 8 + use axum::response::{IntoResponse, Response}; 9 + use axum_template::TemplateEngine; 10 + use chrono::Utc; 11 + use lettre::message::{MultiPart, SinglePart, header}; 12 + use lettre::{AsyncTransport, Message}; 13 + use rand::Rng; 14 + use serde::de::DeserializeOwned; 15 + use serde_json::{Map, Value}; 16 + use sha2::{Digest, Sha256}; 17 + use sqlx::SqlitePool; 18 + use std::env; 19 + use tracing::{error, log}; 20 + 21 + ///Used to generate the email 2fa code 22 + const UPPERCASE_BASE32_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; 23 + 24 + /// The result of a proxied call that attempts to parse JSON. 25 + pub enum ProxiedResult<T> { 26 + /// Successfully parsed JSON body along with original response headers. 27 + Parsed { value: T, _headers: HeaderMap }, 28 + /// Could not or should not parse: return the original (or rebuilt) response as-is. 29 + Passthrough(Response<Body>), 30 + } 31 + 32 + /// Proxy the incoming request to the PDS base URL plus the provided path and attempt to parse 33 + /// the successful response body as JSON into `T`. 34 + /// 35 + pub async fn proxy_get_json<T>( 36 + state: &AppState, 37 + mut req: Request, 38 + path: &str, 39 + ) -> Result<ProxiedResult<T>, StatusCode> 40 + where 41 + T: DeserializeOwned, 42 + { 43 + let uri = format!("{}{}", state.pds_base_url, path); 44 + *req.uri_mut() = Uri::try_from(uri).map_err(|_| StatusCode::BAD_REQUEST)?; 45 + 46 + let result = state 47 + .reverse_proxy_client 48 + .request(req) 49 + .await 50 + .map_err(|_| StatusCode::BAD_REQUEST)? 51 + .into_response(); 52 + 53 + if result.status() != StatusCode::OK { 54 + return Ok(ProxiedResult::Passthrough(result)); 55 + } 56 + 57 + let response_headers = result.headers().clone(); 58 + let body = result.into_body(); 59 + let body_bytes = to_bytes(body, usize::MAX) 60 + .await 61 + .map_err(|_| StatusCode::BAD_REQUEST)?; 62 + 63 + match serde_json::from_slice::<T>(&body_bytes) { 64 + Ok(value) => Ok(ProxiedResult::Parsed { 65 + value, 66 + _headers: response_headers, 67 + }), 68 + Err(err) => { 69 + error!(%err, "failed to parse proxied JSON response; returning original body"); 70 + let mut builder = Response::builder().status(StatusCode::OK); 71 + if let Some(headers) = builder.headers_mut() { 72 + *headers = response_headers; 73 + } 74 + let resp = builder 75 + .body(Body::from(body_bytes)) 76 + .map_err(|_| StatusCode::BAD_REQUEST)?; 77 + Ok(ProxiedResult::Passthrough(resp)) 78 + } 79 + } 80 + } 81 + 82 + /// Build a JSON error response with the required Content-Type header 83 + /// Content-Type: application/json;charset=utf-8 84 + /// Body shape: { "error": string, "message": string } 85 + pub fn json_error_response( 86 + status: StatusCode, 87 + error: impl Into<String>, 88 + message: impl Into<String>, 89 + ) -> Result<Response<Body>, StatusCode> { 90 + let body_str = match serde_json::to_string(&serde_json::json!({ 91 + "error": error.into(), 92 + "message": message.into(), 93 + })) { 94 + Ok(s) => s, 95 + Err(_) => return Err(StatusCode::BAD_REQUEST), 96 + }; 97 + 98 + Response::builder() 99 + .status(status) 100 + .header(CONTENT_TYPE, "application/json;charset=utf-8") 101 + .body(Body::from(body_str)) 102 + .map_err(|_| StatusCode::BAD_REQUEST) 103 + } 104 + 105 + /// Build a JSON error response with the required Content-Type header 106 + /// Content-Type: application/json (oauth endpoint does not like utf ending) 107 + /// Body shape: { "error": string, "error_description": string } 108 + pub fn oauth_json_error_response( 109 + status: StatusCode, 110 + error: impl Into<String>, 111 + message: impl Into<String>, 112 + ) -> Result<Response<Body>, StatusCode> { 113 + let body_str = match serde_json::to_string(&serde_json::json!({ 114 + "error": error.into(), 115 + "error_description": message.into(), 116 + })) { 117 + Ok(s) => s, 118 + Err(_) => return Err(StatusCode::BAD_REQUEST), 119 + }; 120 + 121 + Response::builder() 122 + .status(status) 123 + .header(CONTENT_TYPE, "application/json") 124 + .body(Body::from(body_str)) 125 + .map_err(|_| StatusCode::BAD_REQUEST) 126 + } 127 + 128 + /// Creates a random token of 10 characters for email 2FA 129 + pub fn get_random_token() -> String { 130 + let mut rng = rand::rng(); 131 + 132 + let mut full_code = String::with_capacity(10); 133 + for _ in 0..10 { 134 + let idx = rng.random_range(0..UPPERCASE_BASE32_CHARS.len()); 135 + full_code.push(UPPERCASE_BASE32_CHARS[idx] as char); 136 + } 137 + 138 + let slice_one = &full_code[0..5]; 139 + let slice_two = &full_code[5..10]; 140 + format!("{slice_one}-{slice_two}") 141 + } 142 + 143 + pub enum TokenCheckError { 144 + InvalidToken, 145 + ExpiredToken, 146 + } 147 + 148 + pub enum AuthResult { 149 + WrongIdentityOrPassword, 150 + /// The string here is the email address to create a hint for oauth 151 + TwoFactorRequired(String), 152 + /// User does not have 2FA enabled, or using an app password, or passes it 153 + ProxyThrough, 154 + TokenCheckFailed(TokenCheckError), 155 + } 156 + 157 + pub enum IdentifierType { 158 + Email, 159 + Did, 160 + Handle, 161 + } 162 + 163 + impl IdentifierType { 164 + fn what_is_it(identifier: String) -> Self { 165 + if identifier.contains("@") { 166 + IdentifierType::Email 167 + } else if identifier.contains("did:") { 168 + IdentifierType::Did 169 + } else { 170 + IdentifierType::Handle 171 + } 172 + } 173 + } 174 + 175 + /// Creates a hex string from the password and salt to find app passwords 176 + fn scrypt_hex(password: &str, salt: &str) -> anyhow::Result<String> { 177 + let params = scrypt::Params::new(14, 8, 1, 64)?; 178 + let mut derived = [0u8; 64]; 179 + scrypt::scrypt(password.as_bytes(), salt.as_bytes(), &params, &mut derived)?; 180 + Ok(hex::encode(derived)) 181 + } 182 + 183 + /// Hashes the app password. did is used as the salt. 184 + pub fn hash_app_password(did: &str, password: &str) -> anyhow::Result<String> { 185 + let mut hasher = Sha256::new(); 186 + hasher.update(did.as_bytes()); 187 + let sha = hasher.finalize(); 188 + let salt = hex::encode(&sha[..16]); 189 + let hash_hex = scrypt_hex(password, &salt)?; 190 + Ok(format!("{salt}:{hash_hex}")) 191 + } 192 + 193 + async fn verify_password(password: &str, password_scrypt: &str) -> anyhow::Result<bool> { 194 + // Expected format: "salt:hash" where hash is hex of scrypt(password, salt, 64 bytes) 195 + let mut parts = password_scrypt.splitn(2, ':'); 196 + let salt = match parts.next() { 197 + Some(s) if !s.is_empty() => s, 198 + _ => return Ok(false), 199 + }; 200 + let stored_hash_hex = match parts.next() { 201 + Some(h) if !h.is_empty() => h, 202 + _ => return Ok(false), 203 + }; 204 + 205 + // Derive using the shared helper and compare 206 + let derived_hex = match scrypt_hex(password, salt) { 207 + Ok(h) => h, 208 + Err(_) => return Ok(false), 209 + }; 210 + 211 + Ok(derived_hex.as_str() == stored_hash_hex) 212 + } 213 + 214 + /// Handles the auth checks along with sending a 2fa email 215 + pub async fn preauth_check( 216 + state: &AppState, 217 + identifier: &str, 218 + password: &str, 219 + two_factor_code: Option<String>, 220 + oauth: bool, 221 + ) -> anyhow::Result<AuthResult> { 222 + // Determine identifier type 223 + let id_type = IdentifierType::what_is_it(identifier.to_string()); 224 + 225 + // Query account DB for did and passwordScrypt based on identifier type 226 + let account_row: Option<(String, String, String, String)> = match id_type { 227 + IdentifierType::Email => { 228 + sqlx::query_as::<_, (String, String, String, String)>( 229 + "SELECT account.did, account.passwordScrypt, account.email, actor.handle 230 + FROM actor 231 + LEFT JOIN account ON actor.did = account.did 232 + where account.email = ? LIMIT 1", 233 + ) 234 + .bind(identifier) 235 + .fetch_optional(&state.account_pool) 236 + .await? 237 + } 238 + IdentifierType::Handle => { 239 + sqlx::query_as::<_, (String, String, String, String)>( 240 + "SELECT account.did, account.passwordScrypt, account.email, actor.handle 241 + FROM actor 242 + LEFT JOIN account ON actor.did = account.did 243 + where actor.handle = ? LIMIT 1", 244 + ) 245 + .bind(identifier) 246 + .fetch_optional(&state.account_pool) 247 + .await? 248 + } 249 + IdentifierType::Did => { 250 + sqlx::query_as::<_, (String, String, String, String)>( 251 + "SELECT account.did, account.passwordScrypt, account.email, actor.handle 252 + FROM actor 253 + LEFT JOIN account ON actor.did = account.did 254 + where account.did = ? LIMIT 1", 255 + ) 256 + .bind(identifier) 257 + .fetch_optional(&state.account_pool) 258 + .await? 259 + } 260 + }; 261 + 262 + if let Some((did, password_scrypt, email, handle)) = account_row { 263 + // Verify password before proceeding to 2FA email step 264 + let verified = verify_password(password, &password_scrypt).await?; 265 + if !verified { 266 + if oauth { 267 + //OAuth does not allow app password logins so just go ahead and send it along it's way 268 + return Ok(AuthResult::WrongIdentityOrPassword); 269 + } 270 + //Theres a chance it could be an app password so check that as well 271 + return match verify_app_password(&state.account_pool, &did, password).await { 272 + Ok(valid) => { 273 + if valid { 274 + //Was a valid app password up to the PDS now 275 + Ok(AuthResult::ProxyThrough) 276 + } else { 277 + Ok(AuthResult::WrongIdentityOrPassword) 278 + } 279 + } 280 + Err(err) => { 281 + log::error!("Error checking the app password: {err}"); 282 + Err(err) 283 + } 284 + }; 285 + } 286 + 287 + // Check two-factor requirement for this DID in the gatekeeper DB 288 + let required_opt = sqlx::query_as::<_, (u8,)>( 289 + "SELECT required FROM two_factor_accounts WHERE did = ? LIMIT 1", 290 + ) 291 + .bind(did.clone()) 292 + .fetch_optional(&state.pds_gatekeeper_pool) 293 + .await?; 294 + 295 + let two_factor_required = match required_opt { 296 + Some(row) => row.0 != 0, 297 + None => false, 298 + }; 299 + 300 + if two_factor_required { 301 + //Two factor is required and a taken was provided 302 + if let Some(two_factor_code) = two_factor_code { 303 + //if the two_factor_code is set need to see if we have a valid token 304 + if !two_factor_code.is_empty() { 305 + return match assert_valid_token( 306 + &state.account_pool, 307 + did.clone(), 308 + two_factor_code, 309 + ) 310 + .await 311 + { 312 + Ok(_) => { 313 + let result_of_cleanup = 314 + delete_all_email_tokens(&state.account_pool, did.clone()).await; 315 + if result_of_cleanup.is_err() { 316 + log::error!( 317 + "There was an error deleting the email tokens after login: {:?}", 318 + result_of_cleanup.err() 319 + ) 320 + } 321 + Ok(AuthResult::ProxyThrough) 322 + } 323 + Err(err) => Ok(AuthResult::TokenCheckFailed(err)), 324 + }; 325 + } 326 + } 327 + 328 + return match create_two_factor_token(&state.account_pool, did).await { 329 + Ok(code) => { 330 + let mut email_data = Map::new(); 331 + email_data.insert("token".to_string(), Value::from(code.clone())); 332 + email_data.insert("handle".to_string(), Value::from(handle.clone())); 333 + let email_body = state 334 + .template_engine 335 + .render("two_factor_code.hbs", email_data)?; 336 + let email_subject = env::var("GATEKEEPER_TWO_FACTOR_EMAIL_SUBJECT") 337 + .unwrap_or("Sign in to Bluesky".to_string()); 338 + 339 + let email_message = Message::builder() 340 + //TODO prob get the proper type in the state 341 + .from(state.mailer_from.parse()?) 342 + .to(email.parse()?) 343 + .subject(email_subject) 344 + .multipart( 345 + MultiPart::alternative() // This is composed of two parts. 346 + .singlepart( 347 + SinglePart::builder() 348 + .header(header::ContentType::TEXT_PLAIN) 349 + .body(format!("We received a sign-in request for the account @{handle}. Use the code: {code} to sign in. If this wasn't you, we recommend taking steps to protect your account by changing your password at https://bsky.app/settings.")), // Every message should have a plain text fallback. 350 + ) 351 + .singlepart( 352 + SinglePart::builder() 353 + .header(header::ContentType::TEXT_HTML) 354 + .body(email_body), 355 + ), 356 + )?; 357 + match state.mailer.send(email_message).await { 358 + Ok(_) => Ok(AuthResult::TwoFactorRequired(mask_email(email))), 359 + Err(err) => { 360 + log::error!("Error sending the 2FA email: {err}"); 361 + Err(anyhow!(err)) 362 + } 363 + } 364 + } 365 + Err(err) => { 366 + log::error!("error on creating a 2fa token: {err}"); 367 + Err(anyhow!(err)) 368 + } 369 + }; 370 + } 371 + } 372 + 373 + // No local 2FA requirement (or account not found) 374 + Ok(AuthResult::ProxyThrough) 375 + } 376 + 377 + pub async fn create_two_factor_token( 378 + account_db: &SqlitePool, 379 + did: String, 380 + ) -> anyhow::Result<String> { 381 + let purpose = "2fa_code"; 382 + 383 + let token = get_random_token(); 384 + let right_now = Utc::now(); 385 + 386 + let res = sqlx::query( 387 + "INSERT INTO email_token (purpose, did, token, requestedAt) 388 + VALUES (?, ?, ?, ?) 389 + ON CONFLICT(purpose, did) DO UPDATE SET 390 + token=excluded.token, 391 + requestedAt=excluded.requestedAt 392 + WHERE did=excluded.did", 393 + ) 394 + .bind(purpose) 395 + .bind(&did) 396 + .bind(&token) 397 + .bind(right_now) 398 + .execute(account_db) 399 + .await; 400 + 401 + match res { 402 + Ok(_) => Ok(token), 403 + Err(err) => { 404 + log::error!("Error creating a two factor token: {err}"); 405 + Err(anyhow::anyhow!(err)) 406 + } 407 + } 408 + } 409 + 410 + pub async fn delete_all_email_tokens(account_db: &SqlitePool, did: String) -> anyhow::Result<()> { 411 + sqlx::query("DELETE FROM email_token WHERE did = ?") 412 + .bind(did) 413 + .execute(account_db) 414 + .await?; 415 + Ok(()) 416 + } 417 + 418 + pub async fn assert_valid_token( 419 + account_db: &SqlitePool, 420 + did: String, 421 + token: String, 422 + ) -> Result<(), TokenCheckError> { 423 + let token_upper = token.to_ascii_uppercase(); 424 + let purpose = "2fa_code"; 425 + 426 + let row: Option<(String,)> = sqlx::query_as( 427 + "SELECT requestedAt FROM email_token WHERE purpose = ? AND did = ? AND token = ? LIMIT 1", 428 + ) 429 + .bind(purpose) 430 + .bind(did) 431 + .bind(token_upper) 432 + .fetch_optional(account_db) 433 + .await 434 + .map_err(|err| { 435 + log::error!("Error getting the 2fa token: {err}"); 436 + InvalidToken 437 + })?; 438 + 439 + match row { 440 + None => Err(InvalidToken), 441 + Some(row) => { 442 + // Token lives for 15 minutes 443 + let expiration_ms = 15 * 60_000; 444 + 445 + let requested_at_utc = match chrono::DateTime::parse_from_rfc3339(&row.0) { 446 + Ok(dt) => dt.with_timezone(&Utc), 447 + Err(_) => { 448 + return Err(TokenCheckError::InvalidToken); 449 + } 450 + }; 451 + 452 + let now = Utc::now(); 453 + let age_ms = (now - requested_at_utc).num_milliseconds(); 454 + let expired = age_ms > expiration_ms; 455 + if expired { 456 + return Err(TokenCheckError::ExpiredToken); 457 + } 458 + 459 + Ok(()) 460 + } 461 + } 462 + } 463 + 464 + /// We just need to confirm if it's there or not. Will let the PDS do the actual figuring of permissions 465 + pub async fn verify_app_password( 466 + account_db: &SqlitePool, 467 + did: &str, 468 + password: &str, 469 + ) -> anyhow::Result<bool> { 470 + let password_scrypt = hash_app_password(did, password)?; 471 + 472 + let row: Option<(i64,)> = sqlx::query_as( 473 + "SELECT Count(*) FROM app_password WHERE did = ? AND passwordScrypt = ? LIMIT 1", 474 + ) 475 + .bind(did) 476 + .bind(password_scrypt) 477 + .fetch_optional(account_db) 478 + .await?; 479 + 480 + Ok(match row { 481 + None => false, 482 + Some((count,)) => count > 0, 483 + }) 484 + } 485 + 486 + /// Mask an email address into a hint like "2***0@p***m". 487 + pub fn mask_email(email: String) -> String { 488 + // Basic split on first '@' 489 + let mut parts = email.splitn(2, '@'); 490 + let local = match parts.next() { 491 + Some(l) => l, 492 + None => return email.to_string(), 493 + }; 494 + let domain_rest = match parts.next() { 495 + Some(d) if !d.is_empty() => d, 496 + _ => return email.to_string(), 497 + }; 498 + 499 + // Helper to mask a single label (keep first and last, middle becomes ***). 500 + fn mask_label(s: &str) -> String { 501 + let chars: Vec<char> = s.chars().collect(); 502 + match chars.len() { 503 + 0 => String::new(), 504 + 1 => format!("{}***", chars[0]), 505 + 2 => format!("{}***{}", chars[0], chars[1]), 506 + _ => format!("{}***{}", chars[0], chars[chars.len() - 1]), 507 + } 508 + } 509 + 510 + // Mask local 511 + let masked_local = mask_label(local); 512 + 513 + // Mask first domain label only, keep the rest of the domain intact 514 + let mut dom_parts = domain_rest.splitn(2, '.'); 515 + let first_label = dom_parts.next().unwrap_or(""); 516 + let rest = dom_parts.next(); 517 + let masked_first = mask_label(first_label); 518 + let masked_domain = if let Some(rest) = rest { 519 + format!("{}.{rest}", masked_first) 520 + } else { 521 + masked_first 522 + }; 523 + 524 + format!("{masked_local}@{masked_domain}") 525 + }
+108 -33
src/main.rs
··· 1 - use crate::xrpc::com_atproto_server::{create_session, get_session, update_email}; 2 - use axum::middleware as ax_middleware; 3 - mod middleware; 1 + #![warn(clippy::unwrap_used)] 2 + use crate::oauth_provider::sign_in; 3 + use crate::xrpc::com_atproto_server::{create_account, create_session, get_session, update_email}; 4 4 use axum::body::Body; 5 5 use axum::handler::Handler; 6 6 use axum::http::{Method, header}; 7 + use axum::middleware as ax_middleware; 7 8 use axum::routing::post; 8 9 use axum::{Router, routing::get}; 9 10 use axum_template::engine::Engine; ··· 19 20 use std::{env, net::SocketAddr}; 20 21 use tower_governor::GovernorLayer; 21 22 use tower_governor::governor::GovernorConfigBuilder; 23 + use tower_governor::key_extractor::SmartIpKeyExtractor; 22 24 use tower_http::compression::CompressionLayer; 23 25 use tower_http::cors::{Any, CorsLayer}; 24 - use tracing::{error, log}; 26 + use tracing::log; 25 27 use tracing_subscriber::{EnvFilter, fmt, prelude::*}; 26 28 29 + pub mod helpers; 30 + mod middleware; 31 + mod oauth_provider; 27 32 mod xrpc; 28 33 29 34 type HyperUtilClient = hyper_util::client::legacy::Client<HttpConnector, Body>; ··· 34 39 struct EmailTemplates; 35 40 36 41 #[derive(Clone)] 37 - struct AppState { 42 + pub struct AppState { 38 43 account_pool: SqlitePool, 39 44 pds_gatekeeper_pool: SqlitePool, 40 45 reverse_proxy_client: HyperUtilClient, ··· 73 78 74 79 let intro = "\n\nThis is a PDS gatekeeper\n\nCode: https://tangled.sh/@baileytownsend.dev/pds-gatekeeper\n"; 75 80 76 - let banner = format!(" {}\n{}", body, intro); 81 + let banner = format!(" {body}\n{intro}"); 77 82 78 83 ( 79 84 [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], ··· 84 89 #[tokio::main] 85 90 async fn main() -> Result<(), Box<dyn std::error::Error>> { 86 91 setup_tracing(); 87 - //TODO prod 88 - dotenvy::from_path(Path::new("./pds.env"))?; 89 - let pds_root = env::var("PDS_DATA_DIRECTORY")?; 90 - // let pds_root = "/home/baileytownsend/Documents/code/docker_compose/pds/pds_data"; 91 - let account_db_url = format!("{}/account.sqlite", pds_root); 92 - log::info!("accounts_db_url: {}", account_db_url); 92 + let pds_env_location = 93 + env::var("PDS_ENV_LOCATION").unwrap_or_else(|_| "/pds/pds.env".to_string()); 94 + 95 + let result_of_finding_pds_env = dotenvy::from_path(Path::new(&pds_env_location)); 96 + if let Err(e) = result_of_finding_pds_env { 97 + log::error!( 98 + "Error loading pds.env file (ignore if you loaded your variables in the environment somehow else): {e}" 99 + ); 100 + } 101 + 102 + let pds_root = 103 + env::var("PDS_DATA_DIRECTORY").expect("PDS_DATA_DIRECTORY is not set in your pds.env file"); 104 + let account_db_url = format!("{pds_root}/account.sqlite"); 93 105 94 106 let account_options = SqliteConnectOptions::new() 95 - .journal_mode(SqliteJournalMode::Wal) 96 - .filename(account_db_url); 107 + .filename(account_db_url) 108 + .busy_timeout(Duration::from_secs(5)); 97 109 98 110 let account_pool = SqlitePoolOptions::new() 99 111 .max_connections(5) 100 112 .connect_with(account_options) 101 113 .await?; 102 114 103 - let bells_db_url = format!("{}/pds_gatekeeper.sqlite", pds_root); 115 + let bells_db_url = format!("{pds_root}/pds_gatekeeper.sqlite"); 104 116 let options = SqliteConnectOptions::new() 105 117 .journal_mode(SqliteJournalMode::Wal) 106 118 .filename(bells_db_url) 107 - .create_if_missing(true); 119 + .create_if_missing(true) 120 + .busy_timeout(Duration::from_secs(5)); 108 121 let pds_gatekeeper_pool = SqlitePoolOptions::new() 109 122 .max_connections(5) 110 123 .connect_with(options) 111 124 .await?; 112 125 113 - // Run migrations for the bells_and_whistles database 126 + // Run migrations for the extra database 114 127 // Note: the migrations are embedded at compile time from the given directory 115 128 // sqlx 116 129 sqlx::migrate!("./migrations") ··· 126 139 env::var("PDS_EMAIL_SMTP_URL").expect("PDS_EMAIL_SMTP_URL is not set in your pds.env file"); 127 140 let sent_from = env::var("PDS_EMAIL_FROM_ADDRESS") 128 141 .expect("PDS_EMAIL_FROM_ADDRESS is not set in your pds.env file"); 142 + 129 143 let mailer: AsyncSmtpTransport<Tokio1Executor> = 130 144 AsyncSmtpTransport::<Tokio1Executor>::from_url(smtp_url.as_str())?.build(); 131 145 //Email templates setup 132 146 let mut hbs = Handlebars::new(); 133 - let _ = hbs.register_embed_templates::<EmailTemplates>(); 147 + 148 + let users_email_directory = env::var("GATEKEEPER_EMAIL_TEMPLATES_DIRECTORY"); 149 + if let Ok(users_email_directory) = users_email_directory { 150 + hbs.register_template_file( 151 + "two_factor_code.hbs", 152 + format!("{users_email_directory}/two_factor_code.hbs"), 153 + )?; 154 + } else { 155 + let _ = hbs.register_embed_templates::<EmailTemplates>(); 156 + } 157 + 158 + let pds_base_url = 159 + env::var("PDS_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); 134 160 135 161 let state = AppState { 136 162 account_pool, 137 163 pds_gatekeeper_pool, 138 164 reverse_proxy_client: client, 139 - //TODO should be env prob 140 - pds_base_url: "http://localhost:3000".to_string(), 165 + pds_base_url, 141 166 mailer, 142 167 mailer_from: sent_from, 143 168 template_engine: Engine::from(hbs), ··· 145 170 146 171 // Rate limiting 147 172 //Allows 5 within 60 seconds, and after 60 should drop one off? So hit 5, then goes to 4 after 60 seconds. 148 - let governor_conf = GovernorConfigBuilder::default() 173 + let create_session_governor_conf = GovernorConfigBuilder::default() 174 + .per_second(60) 175 + .burst_size(5) 176 + .key_extractor(SmartIpKeyExtractor) 177 + .finish() 178 + .expect("failed to create governor config for create session. this should not happen and is a bug"); 179 + 180 + // Create a second config with the same settings for the other endpoint 181 + let sign_in_governor_conf = GovernorConfigBuilder::default() 149 182 .per_second(60) 150 183 .burst_size(5) 184 + .key_extractor(SmartIpKeyExtractor) 151 185 .finish() 152 - .unwrap(); 153 - let governor_limiter = governor_conf.limiter().clone(); 186 + .expect( 187 + "failed to create governor config for sign in. this should not happen and is a bug", 188 + ); 189 + 190 + let create_account_limiter_time: Option<String> = 191 + env::var("GATEKEEPER_CREATE_ACCOUNT_PER_SECOND").ok(); 192 + let create_account_limiter_burst: Option<String> = 193 + env::var("GATEKEEPER_CREATE_ACCOUNT_BURST").ok(); 194 + 195 + //Default should be 608 requests per 5 minutes, PDS is 300 per 500 so will never hit it ideally 196 + let mut create_account_governor_conf = GovernorConfigBuilder::default(); 197 + if create_account_limiter_time.is_some() { 198 + let time = create_account_limiter_time 199 + .expect("GATEKEEPER_CREATE_ACCOUNT_PER_SECOND not set") 200 + .parse::<u64>() 201 + .expect("GATEKEEPER_CREATE_ACCOUNT_PER_SECOND must be a valid integer"); 202 + create_account_governor_conf.per_second(time); 203 + } 204 + 205 + if create_account_limiter_burst.is_some() { 206 + let burst = create_account_limiter_burst 207 + .expect("GATEKEEPER_CREATE_ACCOUNT_BURST not set") 208 + .parse::<u32>() 209 + .expect("GATEKEEPER_CREATE_ACCOUNT_BURST must be a valid integer"); 210 + create_account_governor_conf.burst_size(burst); 211 + } 212 + 213 + let create_account_governor_conf = create_account_governor_conf 214 + .key_extractor(SmartIpKeyExtractor) 215 + .finish().expect( 216 + "failed to create governor config for create account. this should not happen and is a bug", 217 + ); 218 + 219 + let create_session_governor_limiter = create_session_governor_conf.limiter().clone(); 220 + let sign_in_governor_limiter = sign_in_governor_conf.limiter().clone(); 221 + let create_account_governor_limiter = create_account_governor_conf.limiter().clone(); 222 + 154 223 let interval = Duration::from_secs(60); 155 224 // a separate background task to clean up 156 225 std::thread::spawn(move || { 157 226 loop { 158 227 std::thread::sleep(interval); 159 - tracing::info!("rate limiting storage size: {}", governor_limiter.len()); 160 - governor_limiter.retain_recent(); 228 + create_session_governor_limiter.retain_recent(); 229 + sign_in_governor_limiter.retain_recent(); 230 + create_account_governor_limiter.retain_recent(); 161 231 } 162 232 }); 163 233 ··· 168 238 169 239 let app = Router::new() 170 240 .route("/", get(root_handler)) 241 + .route("/xrpc/com.atproto.server.getSession", get(get_session)) 171 242 .route( 172 - "/xrpc/com.atproto.server.getSession", 173 - get(get_session).layer(ax_middleware::from_fn(middleware::extract_did)), 243 + "/xrpc/com.atproto.server.updateEmail", 244 + post(update_email).layer(ax_middleware::from_fn(middleware::extract_did)), 174 245 ) 175 246 .route( 176 - "/xrpc/com.atproto.server.updateEmail", 177 - post(update_email).layer(ax_middleware::from_fn(middleware::extract_did)), 247 + "/@atproto/oauth-provider/~api/sign-in", 248 + post(sign_in).layer(GovernorLayer::new(sign_in_governor_conf)), 178 249 ) 179 250 .route( 180 251 "/xrpc/com.atproto.server.createSession", 181 - post(create_session.layer(GovernorLayer::new(governor_conf))), 252 + post(create_session.layer(GovernorLayer::new(create_session_governor_conf))), 253 + ) 254 + .route( 255 + "/xrpc/com.atproto.server.createAccount", 256 + post(create_account).layer(GovernorLayer::new(create_account_governor_conf)), 182 257 ) 183 258 .layer(CompressionLayer::new()) 184 259 .layer(cors) 185 260 .with_state(state); 186 261 187 - let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); 188 - let port: u16 = env::var("PORT") 262 + let host = env::var("GATEKEEPER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); 263 + let port: u16 = env::var("GATEKEEPER_PORT") 189 264 .ok() 190 265 .and_then(|s| s.parse().ok()) 191 266 .unwrap_or(8080); ··· 202 277 .with_graceful_shutdown(shutdown_signal()); 203 278 204 279 if let Err(err) = server.await { 205 - error!(error = %err, "server error"); 280 + log::error!("server error:{err}"); 206 281 } 207 282 208 283 Ok(())
+80 -61
src/middleware.rs
··· 1 - use crate::xrpc::helpers::json_error_response; 1 + use crate::helpers::json_error_response; 2 2 use axum::extract::Request; 3 3 use axum::http::{HeaderMap, StatusCode}; 4 4 use axum::middleware::Next; ··· 7 7 use jwt_compact::{AlgorithmExt, Claims, Token, UntrustedToken, ValidationError}; 8 8 use serde::{Deserialize, Serialize}; 9 9 use std::env; 10 + use tracing::log; 10 11 11 12 #[derive(Clone, Debug)] 12 13 pub struct Did(pub Option<String>); 14 + 15 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 16 + pub enum AuthScheme { 17 + Bearer, 18 + DPoP, 19 + } 13 20 14 21 #[derive(Serialize, Deserialize)] 15 22 pub struct TokenClaims { ··· 17 24 } 18 25 19 26 pub async fn extract_did(mut req: Request, next: Next) -> impl IntoResponse { 20 - let token = extract_bearer(req.headers()); 27 + let auth = extract_auth(req.headers()); 21 28 22 - match token { 23 - Ok(token) => { 24 - match token { 25 - None => { 26 - return json_error_response( 27 - StatusCode::BAD_REQUEST, 28 - "TokenRequired", 29 - "", 30 - ).unwrap(); 31 - } 32 - Some(token) => { 33 - let token = UntrustedToken::new(&token); 34 - //Doing weird unwraps cause I can't do Result for middleware? 35 - if token.is_err() { 36 - return json_error_response( 37 - StatusCode::BAD_REQUEST, 38 - "TokenRequired", 39 - "", 40 - ).unwrap(); 41 - } 42 - let parsed_token = token.unwrap(); 43 - let claims: Result<Claims<TokenClaims>, ValidationError> = 44 - parsed_token.deserialize_claims_unchecked(); 45 - if claims.is_err() { 46 - return json_error_response( 47 - StatusCode::BAD_REQUEST, 48 - "TokenRequired", 49 - "", 50 - ).unwrap(); 51 - } 29 + match auth { 30 + Ok(auth_opt) => { 31 + match auth_opt { 32 + None => json_error_response(StatusCode::BAD_REQUEST, "TokenRequired", "") 33 + .expect("Error creating an error response"), 34 + Some((scheme, token_str)) => { 35 + // For Bearer, validate JWT and extract DID from `sub`. 36 + // For DPoP, we currently only pass through and do not validate here; insert None DID. 37 + match scheme { 38 + AuthScheme::Bearer => { 39 + let token = UntrustedToken::new(&token_str); 40 + if token.is_err() { 41 + return json_error_response( 42 + StatusCode::BAD_REQUEST, 43 + "TokenRequired", 44 + "", 45 + ) 46 + .expect("Error creating an error response"); 47 + } 48 + let parsed_token = token.expect("Already checked for error"); 49 + let claims: Result<Claims<TokenClaims>, ValidationError> = 50 + parsed_token.deserialize_claims_unchecked(); 51 + if claims.is_err() { 52 + return json_error_response( 53 + StatusCode::BAD_REQUEST, 54 + "TokenRequired", 55 + "", 56 + ) 57 + .expect("Error creating an error response"); 58 + } 52 59 53 - let key = Hs256Key::new(env::var("PDS_JWT_SECRET").unwrap()); 54 - let token: Result<Token<TokenClaims>, ValidationError> = 55 - Hs256.validator(&key).validate(&parsed_token); 56 - if token.is_err() { 57 - return json_error_response( 58 - StatusCode::BAD_REQUEST, 59 - "InvalidToken", 60 - "", 61 - ).unwrap(); 60 + let key = Hs256Key::new( 61 + env::var("PDS_JWT_SECRET") 62 + .expect("PDS_JWT_SECRET not set in the pds.env"), 63 + ); 64 + let token: Result<Token<TokenClaims>, ValidationError> = 65 + Hs256.validator(&key).validate(&parsed_token); 66 + if token.is_err() { 67 + return json_error_response( 68 + StatusCode::BAD_REQUEST, 69 + "InvalidToken", 70 + "", 71 + ) 72 + .expect("Error creating an error response"); 73 + } 74 + let token = token.expect("Already checked for error,"); 75 + req.extensions_mut() 76 + .insert(Did(Some(token.claims().custom.sub.clone()))); 77 + } 78 + AuthScheme::DPoP => { 79 + //Not going to worry about oauth email update for now, just always forward to the PDS 80 + req.extensions_mut().insert(Did(None)); 81 + } 62 82 } 63 - let token = token.unwrap(); 64 - //Not going to worry about expiration since it still goes to the PDS 65 83 66 - req.extensions_mut() 67 - .insert(Did(Some(token.claims().custom.sub.clone()))); 68 84 next.run(req).await 69 85 } 70 86 } 71 87 } 72 - Err(_) => { 73 - return json_error_response( 74 - StatusCode::BAD_REQUEST, 75 - "InvalidToken", 76 - "", 77 - ).unwrap(); 88 + Err(err) => { 89 + log::error!("Error extracting token: {err}"); 90 + json_error_response(StatusCode::BAD_REQUEST, "InvalidToken", "") 91 + .expect("Error creating an error response") 78 92 } 79 93 } 80 94 } 81 95 82 - fn extract_bearer(headers: &HeaderMap) -> Result<Option<String>, String> { 96 + fn extract_auth(headers: &HeaderMap) -> Result<Option<(AuthScheme, String)>, String> { 83 97 match headers.get(axum::http::header::AUTHORIZATION) { 84 98 None => Ok(None), 85 - Some(hv) => match hv.to_str() { 86 - Err(_) => Err("Authorization header is not valid".into()), 87 - Ok(s) => { 88 - // Accept forms like: "Bearer <token>" (case-sensitive for the scheme here) 89 - let mut parts = s.splitn(2, ' '); 90 - match (parts.next(), parts.next()) { 91 - (Some("Bearer"), Some(tok)) if !tok.is_empty() => Ok(Some(tok.to_string())), 92 - _ => Err("Authorization header must be in format 'Bearer <token>'".into()), 99 + Some(hv) => { 100 + match hv.to_str() { 101 + Err(_) => Err("Authorization header is not valid".into()), 102 + Ok(s) => { 103 + // Accept forms like: "Bearer <token>" or "DPoP <token>" (case-sensitive for the scheme here) 104 + let mut parts = s.splitn(2, ' '); 105 + match (parts.next(), parts.next()) { 106 + (Some("Bearer"), Some(tok)) if !tok.is_empty() => 107 + Ok(Some((AuthScheme::Bearer, tok.to_string()))), 108 + (Some("DPoP"), Some(tok)) if !tok.is_empty() => 109 + Ok(Some((AuthScheme::DPoP, tok.to_string()))), 110 + _ => Err("Authorization header must be in format 'Bearer <token>' or 'DPoP <token>'".into()), 111 + } 93 112 } 94 113 } 95 - }, 114 + } 96 115 } 97 116 }
+139
src/oauth_provider.rs
··· 1 + use crate::AppState; 2 + use crate::helpers::{AuthResult, oauth_json_error_response, preauth_check}; 3 + use axum::body::Body; 4 + use axum::extract::State; 5 + use axum::http::header::CONTENT_TYPE; 6 + use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; 7 + use axum::response::{IntoResponse, Response}; 8 + use axum::{Json, extract}; 9 + use serde::{Deserialize, Serialize}; 10 + use tracing::log; 11 + 12 + #[derive(Serialize, Deserialize, Clone)] 13 + pub struct SignInRequest { 14 + pub username: String, 15 + pub password: String, 16 + #[serde(skip_serializing_if = "Option::is_none")] 17 + pub remember: Option<bool>, 18 + pub locale: String, 19 + #[serde(skip_serializing_if = "Option::is_none", rename = "emailOtp")] 20 + pub email_otp: Option<String>, 21 + } 22 + 23 + pub async fn sign_in( 24 + State(state): State<AppState>, 25 + headers: HeaderMap, 26 + Json(mut payload): extract::Json<SignInRequest>, 27 + ) -> Result<Response<Body>, StatusCode> { 28 + let identifier = payload.username.clone(); 29 + let password = payload.password.clone(); 30 + let auth_factor_token = payload.email_otp.clone(); 31 + 32 + match preauth_check(&state, &identifier, &password, auth_factor_token, true).await { 33 + Ok(result) => match result { 34 + AuthResult::WrongIdentityOrPassword => oauth_json_error_response( 35 + StatusCode::BAD_REQUEST, 36 + "invalid_request", 37 + "Invalid identifier or password", 38 + ), 39 + AuthResult::TwoFactorRequired(masked_email) => { 40 + let body_str = match serde_json::to_string(&serde_json::json!({ 41 + "error": "second_authentication_factor_required", 42 + "error_description": format!("emailOtp authentication factor required (hint: {})", masked_email), 43 + "type": "emailOtp", 44 + "hint": masked_email, 45 + })) { 46 + Ok(s) => s, 47 + Err(_) => return Err(StatusCode::BAD_REQUEST), 48 + }; 49 + 50 + Response::builder() 51 + .status(StatusCode::BAD_REQUEST) 52 + .header(CONTENT_TYPE, "application/json") 53 + .body(Body::from(body_str)) 54 + .map_err(|_| StatusCode::BAD_REQUEST) 55 + } 56 + AuthResult::ProxyThrough => { 57 + //No 2FA or already passed 58 + let uri = format!( 59 + "{}{}", 60 + state.pds_base_url, "/@atproto/oauth-provider/~api/sign-in" 61 + ); 62 + 63 + let mut req = axum::http::Request::post(uri); 64 + if let Some(req_headers) = req.headers_mut() { 65 + // Copy headers but remove problematic ones. There was an issue with the PDS not parsing the body fully if i forwarded all headers 66 + copy_filtered_headers(&headers, req_headers); 67 + //Setting the content type to application/json manually 68 + req_headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); 69 + } 70 + 71 + //Clears the email_otp because the pds will reject a request with it. 72 + payload.email_otp = None; 73 + let payload_bytes = 74 + serde_json::to_vec(&payload).map_err(|_| StatusCode::BAD_REQUEST)?; 75 + 76 + let req = req 77 + .body(Body::from(payload_bytes)) 78 + .map_err(|_| StatusCode::BAD_REQUEST)?; 79 + 80 + let proxied = state 81 + .reverse_proxy_client 82 + .request(req) 83 + .await 84 + .map_err(|_| StatusCode::BAD_REQUEST)? 85 + .into_response(); 86 + 87 + Ok(proxied) 88 + } 89 + //Ignoring the type of token check failure. Looks like oauth on the entry treads them the same. 90 + AuthResult::TokenCheckFailed(_) => oauth_json_error_response( 91 + StatusCode::BAD_REQUEST, 92 + "invalid_request", 93 + "Unable to sign-in due to an unexpected server error", 94 + ), 95 + }, 96 + Err(err) => { 97 + log::error!( 98 + "Error during pre-auth check. This happens on the oauth signin endpoint when trying to decide if the user has access:\n {err}" 99 + ); 100 + oauth_json_error_response( 101 + StatusCode::BAD_REQUEST, 102 + "pds_gatekeeper_error", 103 + "This error was not generated by the PDS, but PDS Gatekeeper. Please contact your PDS administrator for help and for them to review the server logs.", 104 + ) 105 + } 106 + } 107 + } 108 + 109 + fn is_disallowed_header(name: &HeaderName) -> bool { 110 + // possible problematic headers with proxying 111 + matches!( 112 + name.as_str(), 113 + "connection" 114 + | "keep-alive" 115 + | "proxy-authenticate" 116 + | "proxy-authorization" 117 + | "te" 118 + | "trailer" 119 + | "transfer-encoding" 120 + | "upgrade" 121 + | "host" 122 + | "content-length" 123 + | "content-encoding" 124 + | "expect" 125 + | "accept-encoding" 126 + ) 127 + } 128 + 129 + fn copy_filtered_headers(src: &HeaderMap, dst: &mut HeaderMap) { 130 + for (name, value) in src.iter() { 131 + if is_disallowed_header(name) { 132 + continue; 133 + } 134 + // Only copy valid headers 135 + if let Ok(hv) = HeaderValue::from_bytes(value.as_bytes()) { 136 + dst.insert(name.clone(), hv); 137 + } 138 + } 139 + }
+159 -258
src/xrpc/com_atproto_server.rs
··· 1 1 use crate::AppState; 2 + use crate::helpers::{ 3 + AuthResult, ProxiedResult, TokenCheckError, json_error_response, preauth_check, proxy_get_json, 4 + }; 2 5 use crate::middleware::Did; 3 - use crate::xrpc::helpers::{ProxiedResult, json_error_response, proxy_get_json}; 4 6 use axum::body::Body; 5 7 use axum::extract::State; 6 8 use axum::http::{HeaderMap, StatusCode}; 7 9 use axum::response::{IntoResponse, Response}; 8 10 use axum::{Extension, Json, debug_handler, extract, extract::Request}; 9 - use axum_template::TemplateEngine; 10 - use lettre::message::{MultiPart, SinglePart, header}; 11 - use lettre::{AsyncTransport, Message}; 12 11 use serde::{Deserialize, Serialize}; 13 12 use serde_json; 14 - use serde_json::Value; 15 - use serde_json::value::Map; 16 13 use tracing::log; 17 14 18 15 #[derive(Serialize, Deserialize, Debug, Clone)] ··· 58 55 pub struct CreateSessionRequest { 59 56 identifier: String, 60 57 password: String, 61 - auth_factor_token: String, 62 - allow_takendown: bool, 63 - } 64 - 65 - pub enum AuthResult { 66 - WrongIdentityOrPassword, 67 - TwoFactorRequired, 68 - TwoFactorFailed, 69 - /// User does not have 2FA enabled, or passes it 70 - ProxyThrough, 71 - } 72 - 73 - pub enum IdentifierType { 74 - Email, 75 - DID, 76 - Handle, 77 - } 78 - 79 - impl IdentifierType { 80 - fn what_is_it(identifier: String) -> Self { 81 - if identifier.contains("@") { 82 - IdentifierType::Email 83 - } else if identifier.contains("did:") { 84 - IdentifierType::DID 85 - } else { 86 - IdentifierType::Handle 87 - } 88 - } 89 - } 90 - 91 - async fn verify_password(password: &str, password_scrypt: &str) -> Result<bool, StatusCode> { 92 - // Expected format: "salt:hash" where hash is hex of scrypt(password, salt, 64 bytes) 93 - let mut parts = password_scrypt.splitn(2, ':'); 94 - let salt = match parts.next() { 95 - Some(s) if !s.is_empty() => s, 96 - _ => return Ok(false), 97 - }; 98 - let stored_hash_hex = match parts.next() { 99 - Some(h) if !h.is_empty() => h, 100 - _ => return Ok(false), 101 - }; 102 - 103 - //Sets up scrypt to mimic node's scrypt 104 - let params = match scrypt::Params::new(14, 8, 1, 64) { 105 - Ok(p) => p, 106 - Err(_) => return Ok(false), 107 - }; 108 - let mut derived = [0u8; 64]; 109 - if scrypt::scrypt(password.as_bytes(), salt.as_bytes(), &params, &mut derived).is_err() { 110 - return Ok(false); 111 - } 112 - 113 - let stored_bytes = match hex::decode(stored_hash_hex) { 114 - Ok(b) => b, 115 - Err(e) => { 116 - log::error!("Error decoding stored hash: {}", e); 117 - return Ok(false); 118 - } 119 - }; 120 - 121 - Ok(derived.as_slice() == stored_bytes.as_slice()) 122 - } 123 - 124 - async fn preauth_check( 125 - state: &AppState, 126 - identifier: &str, 127 - password: &str, 128 - ) -> Result<AuthResult, StatusCode> { 129 - // Determine identifier type 130 - let id_type = IdentifierType::what_is_it(identifier.to_string()); 131 - 132 - // Query account DB for did and passwordScrypt based on identifier type 133 - let account_row: Option<(String, String, String)> = match id_type { 134 - IdentifierType::Email => sqlx::query_as::<_, (String, String, String)>( 135 - "SELECT did, passwordScrypt, account.email FROM account WHERE email = ? LIMIT 1", 136 - ) 137 - .bind(identifier) 138 - .fetch_optional(&state.account_pool) 139 - .await 140 - .map_err(|_| StatusCode::BAD_REQUEST)?, 141 - IdentifierType::Handle => sqlx::query_as::<_, (String, String, String)>( 142 - "SELECT account.did, account.passwordScrypt, account.email 143 - FROM actor 144 - LEFT JOIN account ON actor.did = account.did 145 - where actor.handle =? LIMIT 1", 146 - ) 147 - .bind(identifier) 148 - .fetch_optional(&state.account_pool) 149 - .await 150 - .map_err(|_| StatusCode::BAD_REQUEST)?, 151 - IdentifierType::DID => sqlx::query_as::<_, (String, String, String)>( 152 - "SELECT did, passwordScrypt, account.email FROM account WHERE did = ? LIMIT 1", 153 - ) 154 - .bind(identifier) 155 - .fetch_optional(&state.account_pool) 156 - .await 157 - .map_err(|_| StatusCode::BAD_REQUEST)?, 158 - }; 159 - 160 - if let Some((did, password_scrypt, email)) = account_row { 161 - // Check two-factor requirement for this DID in the gatekeeper DB 162 - let required_opt = sqlx::query_as::<_, (u8,)>( 163 - "SELECT required FROM two_factor_accounts WHERE did = ? LIMIT 1", 164 - ) 165 - .bind(&did) 166 - .fetch_optional(&state.pds_gatekeeper_pool) 167 - .await 168 - .map_err(|_| StatusCode::BAD_REQUEST)?; 169 - 170 - let two_factor_required = match required_opt { 171 - Some(row) => row.0 != 0, 172 - None => false, 173 - }; 174 - 175 - if two_factor_required { 176 - // Verify password before proceeding to 2FA email step 177 - let verified = verify_password(password, &password_scrypt).await?; 178 - if !verified { 179 - return Ok(AuthResult::WrongIdentityOrPassword); 180 - } 181 - let mut email_data = Map::new(); 182 - //TODO these need real values 183 - let token = "test".to_string(); 184 - let handle = "baileytownsend.dev".to_string(); 185 - email_data.insert("token".to_string(), Value::from(token.clone())); 186 - email_data.insert("handle".to_string(), Value::from(handle.clone())); 187 - //TODO bad unwrap 188 - let email_body = state 189 - .template_engine 190 - .render("two_factor_code.hbs", email_data) 191 - .unwrap(); 192 - 193 - let email = Message::builder() 194 - //TODO prob get the proper type in the state 195 - .from(state.mailer_from.parse().unwrap()) 196 - .to(email.parse().unwrap()) 197 - .subject("Sign in to Bluesky") 198 - .multipart( 199 - MultiPart::alternative() // This is composed of two parts. 200 - .singlepart( 201 - SinglePart::builder() 202 - .header(header::ContentType::TEXT_PLAIN) 203 - .body(format!("We received a sign-in request for the account @{}. Use the code: {} to sign in. If this wasn't you, we recommend taking steps to protect your account by changing your password at https://bsky.app/settings.", handle, token)), // Every message should have a plain text fallback. 204 - ) 205 - .singlepart( 206 - SinglePart::builder() 207 - .header(header::ContentType::TEXT_HTML) 208 - .body(email_body), 209 - ), 210 - ) 211 - //TODO bad 212 - .unwrap(); 213 - return match state.mailer.send(email).await { 214 - Ok(_) => Ok(AuthResult::TwoFactorRequired), 215 - Err(err) => { 216 - log::error!("Error sending the 2FA email: {}", err); 217 - Err(StatusCode::BAD_REQUEST) 218 - } 219 - }; 220 - } 221 - } 222 - 223 - // No local 2FA requirement (or account not found) 224 - Ok(AuthResult::ProxyThrough) 58 + #[serde(skip_serializing_if = "Option::is_none")] 59 + auth_factor_token: Option<String>, 60 + #[serde(skip_serializing_if = "Option::is_none")] 61 + allow_takendown: Option<bool>, 225 62 } 226 63 227 64 pub async fn create_session( ··· 231 68 ) -> Result<Response<Body>, StatusCode> { 232 69 let identifier = payload.identifier.clone(); 233 70 let password = payload.password.clone(); 71 + let auth_factor_token = payload.auth_factor_token.clone(); 234 72 235 73 // Run the shared pre-auth logic to validate and check 2FA requirement 236 - match preauth_check(&state, &identifier, &password).await? { 237 - AuthResult::WrongIdentityOrPassword => json_error_response( 238 - StatusCode::UNAUTHORIZED, 239 - "AuthenticationRequired", 240 - "Invalid identifier or password", 241 - ), 242 - AuthResult::TwoFactorRequired => { 243 - // Email sending step can be handled here if needed in the future. 244 - json_error_response( 74 + match preauth_check(&state, &identifier, &password, auth_factor_token, false).await { 75 + Ok(result) => match result { 76 + AuthResult::WrongIdentityOrPassword => json_error_response( 245 77 StatusCode::UNAUTHORIZED, 246 - "AuthFactorTokenRequired", 247 - "A sign in code has been sent to your email address", 248 - ) 249 - } 250 - AuthResult::TwoFactorFailed => { 251 - //Not sure what the errors are for this response is yet 252 - json_error_response(StatusCode::UNAUTHORIZED, "PLACEHOLDER", "PLACEHOLDER") 253 - } 254 - AuthResult::ProxyThrough => { 255 - //No 2FA or already passed 256 - let uri = format!( 257 - "{}{}", 258 - state.pds_base_url, "/xrpc/com.atproto.server.createSession" 259 - ); 78 + "AuthenticationRequired", 79 + "Invalid identifier or password", 80 + ), 81 + AuthResult::TwoFactorRequired(_) => { 82 + // Email sending step can be handled here if needed in the future. 83 + json_error_response( 84 + StatusCode::UNAUTHORIZED, 85 + "AuthFactorTokenRequired", 86 + "A sign in code has been sent to your email address", 87 + ) 88 + } 89 + AuthResult::ProxyThrough => { 90 + //No 2FA or already passed 91 + let uri = format!( 92 + "{}{}", 93 + state.pds_base_url, "/xrpc/com.atproto.server.createSession" 94 + ); 260 95 261 - let mut req = axum::http::Request::post(uri); 262 - if let Some(req_headers) = req.headers_mut() { 263 - req_headers.extend(headers.clone()); 264 - } 96 + let mut req = axum::http::Request::post(uri); 97 + if let Some(req_headers) = req.headers_mut() { 98 + req_headers.extend(headers.clone()); 99 + } 265 100 266 - let payload_bytes = 267 - serde_json::to_vec(&payload).map_err(|_| StatusCode::BAD_REQUEST)?; 268 - let req = req 269 - .body(Body::from(payload_bytes)) 270 - .map_err(|_| StatusCode::BAD_REQUEST)?; 101 + let payload_bytes = 102 + serde_json::to_vec(&payload).map_err(|_| StatusCode::BAD_REQUEST)?; 103 + let req = req 104 + .body(Body::from(payload_bytes)) 105 + .map_err(|_| StatusCode::BAD_REQUEST)?; 271 106 272 - let proxied = state 273 - .reverse_proxy_client 274 - .request(req) 275 - .await 276 - .map_err(|_| StatusCode::BAD_REQUEST)? 277 - .into_response(); 107 + let proxied = state 108 + .reverse_proxy_client 109 + .request(req) 110 + .await 111 + .map_err(|_| StatusCode::BAD_REQUEST)? 112 + .into_response(); 278 113 279 - Ok(proxied) 114 + Ok(proxied) 115 + } 116 + AuthResult::TokenCheckFailed(err) => match err { 117 + TokenCheckError::InvalidToken => { 118 + json_error_response(StatusCode::BAD_REQUEST, "InvalidToken", "Token is invalid") 119 + } 120 + TokenCheckError::ExpiredToken => { 121 + json_error_response(StatusCode::BAD_REQUEST, "ExpiredToken", "Token is expired") 122 + } 123 + }, 124 + }, 125 + Err(err) => { 126 + log::error!( 127 + "Error during pre-auth check. This happens on the create_session endpoint when trying to decide if the user has access:\n {err}" 128 + ); 129 + json_error_response( 130 + StatusCode::INTERNAL_SERVER_ERROR, 131 + "InternalServerError", 132 + "This error was not generated by the PDS, but PDS Gatekeeper. Please contact your PDS administrator for help and for them to review the server logs.", 133 + ) 280 134 } 281 135 } 282 136 } ··· 290 144 ) -> Result<Response<Body>, StatusCode> { 291 145 //If email auth is not set at all it is a update email address 292 146 let email_auth_not_set = payload.email_auth_factor.is_none(); 293 - //If email aurth is set it is to either turn on or off 2fa 147 + //If email auth is set it is to either turn on or off 2fa 294 148 let email_auth_update = payload.email_auth_factor.unwrap_or(false); 295 149 296 - // Email update asked for 297 - if email_auth_update { 298 - let email = payload.email.clone(); 299 - let email_confirmed = sqlx::query_as::<_, (String,)>( 300 - "SELECT did FROM account WHERE emailConfirmedAt IS NOT NULL AND email = ?", 301 - ) 302 - .bind(&email) 303 - .fetch_optional(&state.account_pool) 304 - .await 305 - .map_err(|_| StatusCode::BAD_REQUEST)?; 306 - 307 - //Since the email is already confirmed we can enable 2fa 308 - return match email_confirmed { 309 - None => Err(StatusCode::BAD_REQUEST), 310 - Some(did_row) => { 311 - let _ = sqlx::query( 312 - "INSERT INTO two_factor_accounts (did, required) VALUES (?, 1) ON CONFLICT(did) DO UPDATE SET required = 1", 313 - ) 314 - .bind(&did_row.0) 315 - .execute(&state.pds_gatekeeper_pool) 316 - .await 317 - .map_err(|_| StatusCode::BAD_REQUEST)?; 318 - 319 - Ok(StatusCode::OK.into_response()) 320 - } 321 - }; 322 - } 150 + //This means the middleware successfully extracted a did from the request, if not it just needs to be forward to the PDS 151 + //This is also empty if it is an oauth request, which is not supported by gatekeeper turning on 2fa since the dpop stuff needs to be implemented 152 + let did_is_not_empty = did.0.is_some(); 323 153 324 - // User wants auth turned off 325 - if !email_auth_update && !email_auth_not_set { 326 - //User wants auth turned off and has a token 327 - if let Some(token) = &payload.token { 328 - let token_found = sqlx::query_as::<_, (String,)>( 329 - "SELECT token FROM email_token WHERE token = ? AND did = ? AND purpose = 'update_email'", 154 + if did_is_not_empty { 155 + // Email update asked for 156 + if email_auth_update { 157 + let email = payload.email.clone(); 158 + let email_confirmed = match sqlx::query_as::<_, (String,)>( 159 + "SELECT did FROM account WHERE emailConfirmedAt IS NOT NULL AND email = ?", 330 160 ) 331 - .bind(token) 332 - .bind(&did.0) 161 + .bind(&email) 333 162 .fetch_optional(&state.account_pool) 334 163 .await 335 - .map_err(|_| StatusCode::BAD_REQUEST)?; 164 + { 165 + Ok(row) => row, 166 + Err(err) => { 167 + log::error!("Error checking if email is confirmed: {err}"); 168 + return Err(StatusCode::BAD_REQUEST); 169 + } 170 + }; 336 171 337 - if token_found.is_some() { 338 - let _ = sqlx::query( 339 - "INSERT INTO two_factor_accounts (did, required) VALUES (?, 0) ON CONFLICT(did) DO UPDATE SET required = 0", 172 + //Since the email is already confirmed we can enable 2fa 173 + return match email_confirmed { 174 + None => Err(StatusCode::BAD_REQUEST), 175 + Some(did_row) => { 176 + let _ = sqlx::query( 177 + "INSERT INTO two_factor_accounts (did, required) VALUES (?, 1) ON CONFLICT(did) DO UPDATE SET required = 1", 178 + ) 179 + .bind(&did_row.0) 180 + .execute(&state.pds_gatekeeper_pool) 181 + .await 182 + .map_err(|_| StatusCode::BAD_REQUEST)?; 183 + 184 + Ok(StatusCode::OK.into_response()) 185 + } 186 + }; 187 + } 188 + 189 + // User wants auth turned off 190 + if !email_auth_update && !email_auth_not_set { 191 + //User wants auth turned off and has a token 192 + if let Some(token) = &payload.token { 193 + let token_found = match sqlx::query_as::<_, (String,)>( 194 + "SELECT token FROM email_token WHERE token = ? AND did = ? AND purpose = 'update_email'", 340 195 ) 341 - .bind(&did.0) 342 - .execute(&state.pds_gatekeeper_pool) 343 - .await 344 - .map_err(|_| StatusCode::BAD_REQUEST)?; 196 + .bind(token) 197 + .bind(&did.0) 198 + .fetch_optional(&state.account_pool) 199 + .await{ 200 + Ok(token) => token, 201 + Err(err) => { 202 + log::error!("Error checking if token is valid: {err}"); 203 + return Err(StatusCode::BAD_REQUEST); 204 + } 205 + }; 345 206 346 - return Ok(StatusCode::OK.into_response()); 347 - } else { 348 - return Err(StatusCode::BAD_REQUEST); 207 + return if token_found.is_some() { 208 + //TODO I think there may be a bug here and need to do some retry logic 209 + // First try was erroring, seconds was allowing 210 + match sqlx::query( 211 + "INSERT INTO two_factor_accounts (did, required) VALUES (?, 0) ON CONFLICT(did) DO UPDATE SET required = 0", 212 + ) 213 + .bind(&did.0) 214 + .execute(&state.pds_gatekeeper_pool) 215 + .await { 216 + Ok(_) => {} 217 + Err(err) => { 218 + log::error!("Error updating email auth: {err}"); 219 + return Err(StatusCode::BAD_REQUEST); 220 + } 221 + } 222 + 223 + Ok(StatusCode::OK.into_response()) 224 + } else { 225 + Err(StatusCode::BAD_REQUEST) 226 + }; 349 227 } 350 228 } 351 229 } 352 - 353 - // Updating the acutal email address 230 + // Updating the actual email address by sending it on to the PDS 354 231 let uri = format!( 355 232 "{}{}", 356 233 state.pds_base_url, "/xrpc/com.atproto.server.updateEmail" ··· 405 282 ProxiedResult::Passthrough(resp) => Ok(resp), 406 283 } 407 284 } 285 + 286 + pub async fn create_account( 287 + State(state): State<AppState>, 288 + mut req: Request, 289 + ) -> Result<Response<Body>, StatusCode> { 290 + //TODO if I add the block of only accounts authenticated just take the body as json here and grab the lxm token. No middle ware is needed 291 + 292 + let uri = format!( 293 + "{}{}", 294 + state.pds_base_url, "/xrpc/com.atproto.server.createAccount" 295 + ); 296 + 297 + // Rewrite the URI to point at the upstream PDS; keep headers, method, and body intact 298 + *req.uri_mut() = uri.parse().map_err(|_| StatusCode::BAD_REQUEST)?; 299 + 300 + let proxied = state 301 + .reverse_proxy_client 302 + .request(req) 303 + .await 304 + .map_err(|_| StatusCode::BAD_REQUEST)? 305 + .into_response(); 306 + 307 + Ok(proxied) 308 + }
-150
src/xrpc/helpers.rs
··· 1 - use axum::body::{Body, to_bytes}; 2 - use axum::extract::Request; 3 - use axum::http::{HeaderMap, Method, StatusCode, Uri}; 4 - use axum::http::header::CONTENT_TYPE; 5 - use axum::response::{IntoResponse, Response}; 6 - use serde::de::DeserializeOwned; 7 - use tracing::error; 8 - 9 - use crate::AppState; 10 - 11 - /// The result of a proxied call that attempts to parse JSON. 12 - pub enum ProxiedResult<T> { 13 - /// Successfully parsed JSON body along with original response headers. 14 - Parsed { value: T, _headers: HeaderMap }, 15 - /// Could not or should not parse: return the original (or rebuilt) response as-is. 16 - Passthrough(Response<Body>), 17 - } 18 - 19 - /// Proxy the incoming request to the PDS base URL plus the provided path and attempt to parse 20 - /// the successful response body as JSON into `T`. 21 - /// 22 - /// Behavior: 23 - /// - If the proxied response is non-200, returns Passthrough with the original response. 24 - /// - If the response is 200 but JSON parsing fails, returns Passthrough with the original body and headers. 25 - /// - If parsing succeeds, returns Parsed { value, headers }. 26 - pub async fn proxy_get_json<T>( 27 - state: &AppState, 28 - mut req: Request, 29 - path: &str, 30 - ) -> Result<ProxiedResult<T>, StatusCode> 31 - where 32 - T: DeserializeOwned, 33 - { 34 - let uri = format!("{}{}", state.pds_base_url, path); 35 - *req.uri_mut() = Uri::try_from(uri).map_err(|_| StatusCode::BAD_REQUEST)?; 36 - 37 - let result = state 38 - .reverse_proxy_client 39 - .request(req) 40 - .await 41 - .map_err(|_| StatusCode::BAD_REQUEST)? 42 - .into_response(); 43 - 44 - if result.status() != StatusCode::OK { 45 - return Ok(ProxiedResult::Passthrough(result)); 46 - } 47 - 48 - let response_headers = result.headers().clone(); 49 - let body = result.into_body(); 50 - let body_bytes = to_bytes(body, usize::MAX) 51 - .await 52 - .map_err(|_| StatusCode::BAD_REQUEST)?; 53 - 54 - match serde_json::from_slice::<T>(&body_bytes) { 55 - Ok(value) => Ok(ProxiedResult::Parsed { 56 - value, 57 - _headers: response_headers, 58 - }), 59 - Err(err) => { 60 - error!(%err, "failed to parse proxied JSON response; returning original body"); 61 - let mut builder = Response::builder().status(StatusCode::OK); 62 - if let Some(headers) = builder.headers_mut() { 63 - *headers = response_headers; 64 - } 65 - let resp = builder 66 - .body(Body::from(body_bytes)) 67 - .map_err(|_| StatusCode::BAD_REQUEST)?; 68 - Ok(ProxiedResult::Passthrough(resp)) 69 - } 70 - } 71 - } 72 - 73 - /// Proxy the incoming request as a POST to the PDS base URL plus the provided path and attempt to parse 74 - /// the successful response body as JSON into `T`. 75 - /// 76 - /// Behavior mirrors `proxy_get_json`: 77 - /// - If the proxied response is non-200, returns Passthrough with the original response. 78 - /// - If the response is 200 but JSON parsing fails, returns Passthrough with the original body and headers. 79 - /// - If parsing succeeds, returns Parsed { value, headers }. 80 - pub async fn _proxy_post_json<T>( 81 - state: &AppState, 82 - mut req: Request, 83 - path: &str, 84 - ) -> Result<ProxiedResult<T>, StatusCode> 85 - where 86 - T: DeserializeOwned, 87 - { 88 - let uri = format!("{}{}", state.pds_base_url, path); 89 - *req.uri_mut() = Uri::try_from(uri).map_err(|_| StatusCode::BAD_REQUEST)?; 90 - *req.method_mut() = Method::POST; 91 - 92 - let result = state 93 - .reverse_proxy_client 94 - .request(req) 95 - .await 96 - .map_err(|_| StatusCode::BAD_REQUEST)? 97 - .into_response(); 98 - 99 - if result.status() != StatusCode::OK { 100 - return Ok(ProxiedResult::Passthrough(result)); 101 - } 102 - 103 - let response_headers = result.headers().clone(); 104 - let body = result.into_body(); 105 - let body_bytes = to_bytes(body, usize::MAX) 106 - .await 107 - .map_err(|_| StatusCode::BAD_REQUEST)?; 108 - 109 - match serde_json::from_slice::<T>(&body_bytes) { 110 - Ok(value) => Ok(ProxiedResult::Parsed { 111 - value, 112 - _headers: response_headers, 113 - }), 114 - Err(err) => { 115 - error!(%err, "failed to parse proxied JSON response (POST); returning original body"); 116 - let mut builder = Response::builder().status(StatusCode::OK); 117 - if let Some(headers) = builder.headers_mut() { 118 - *headers = response_headers; 119 - } 120 - let resp = builder 121 - .body(Body::from(body_bytes)) 122 - .map_err(|_| StatusCode::BAD_REQUEST)?; 123 - Ok(ProxiedResult::Passthrough(resp)) 124 - } 125 - } 126 - } 127 - 128 - 129 - /// Build a JSON error response with the required Content-Type header 130 - /// Content-Type: application/json;charset=utf-8 131 - /// Body shape: { "error": string, "message": string } 132 - pub fn json_error_response( 133 - status: StatusCode, 134 - error: impl Into<String>, 135 - message: impl Into<String>, 136 - ) -> Result<Response<Body>, StatusCode> { 137 - let body_str = match serde_json::to_string(&serde_json::json!({ 138 - "error": error.into(), 139 - "message": message.into(), 140 - })) { 141 - Ok(s) => s, 142 - Err(_) => return Err(StatusCode::BAD_REQUEST), 143 - }; 144 - 145 - Response::builder() 146 - .status(status) 147 - .header(CONTENT_TYPE, "application/json;charset=utf-8") 148 - .body(Body::from(body_str)) 149 - .map_err(|_| StatusCode::BAD_REQUEST) 150 - }
-1
src/xrpc/mod.rs
··· 1 1 pub mod com_atproto_server; 2 - pub mod helpers;