Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +223 −0
| @@ -155,6 +155,17 @@ dependencies = [ | |||
| 155 | ] | 155 | ] |
| 156 | 156 | ||
| 157 | [[package]] | 157 | [[package]] |
| 158 | name = "async-trait" | ||
| 159 | version = "0.1.91" | ||
| 160 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 161 | checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" | ||
| 162 | dependencies = [ | ||
| 163 | "proc-macro2", | ||
| 164 | "quote", | ||
| 165 | "syn 3.0.3", | ||
| 166 | ] | ||
| 167 | |||
| 168 | [[package]] | ||
| 158 | name = "atoi" | 169 | name = "atoi" |
| 159 | version = "2.0.0" | 170 | version = "2.0.0" |
| 160 | source = "registry+https://github.com/rust-lang/crates.io-index" | 171 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -467,6 +478,7 @@ dependencies = [ | |||
| 467 | "clap", | 478 | "clap", |
| 468 | "config", | 479 | "config", |
| 469 | "git", | 480 | "git", |
| 481 | "mail", | ||
| 470 | "model", | 482 | "model", |
| 471 | "rpassword", | 483 | "rpassword", |
| 472 | "serde", | 484 | "serde", |
| @@ -546,6 +558,22 @@ dependencies = [ | |||
| 546 | ] | 558 | ] |
| 547 | 559 | ||
| 548 | [[package]] | 560 | [[package]] |
| 561 | name = "core-foundation" | ||
| 562 | version = "0.10.1" | ||
| 563 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 564 | checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" | ||
| 565 | dependencies = [ | ||
| 566 | "core-foundation-sys", | ||
| 567 | "libc", | ||
| 568 | ] | ||
| 569 | |||
| 570 | [[package]] | ||
| 571 | name = "core-foundation-sys" | ||
| 572 | version = "0.8.7" | ||
| 573 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 574 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | ||
| 575 | |||
| 576 | [[package]] | ||
| 549 | name = "cpufeatures" | 577 | name = "cpufeatures" |
| 550 | version = "0.2.17" | 578 | version = "0.2.17" |
| 551 | source = "registry+https://github.com/rust-lang/crates.io-index" | 579 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -972,6 +1000,22 @@ dependencies = [ | |||
| 972 | ] | 1000 | ] |
| 973 | 1001 | ||
| 974 | [[package]] | 1002 | [[package]] |
| 1003 | name = "email-encoding" | ||
| 1004 | version = "0.4.1" | ||
| 1005 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1006 | checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" | ||
| 1007 | dependencies = [ | ||
| 1008 | "base64", | ||
| 1009 | "memchr", | ||
| 1010 | ] | ||
| 1011 | |||
| 1012 | [[package]] | ||
| 1013 | name = "email_address" | ||
| 1014 | version = "0.2.9" | ||
| 1015 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1016 | checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" | ||
| 1017 | |||
| 1018 | [[package]] | ||
| 975 | name = "encode_unicode" | 1019 | name = "encode_unicode" |
| 976 | version = "1.0.0" | 1020 | version = "1.0.0" |
| 977 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1021 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1100,6 +1144,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1100 | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" | 1144 | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" |
| 1101 | 1145 | ||
| 1102 | [[package]] | 1146 | [[package]] |
| 1147 | name = "foreign-types" | ||
| 1148 | version = "0.3.2" | ||
| 1149 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1150 | checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" | ||
| 1151 | dependencies = [ | ||
| 1152 | "foreign-types-shared", | ||
| 1153 | ] | ||
| 1154 | |||
| 1155 | [[package]] | ||
| 1156 | name = "foreign-types-shared" | ||
| 1157 | version = "0.1.1" | ||
| 1158 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1159 | checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" | ||
| 1160 | |||
| 1161 | [[package]] | ||
| 1103 | name = "form_urlencoded" | 1162 | name = "form_urlencoded" |
| 1104 | version = "1.2.2" | 1163 | version = "1.2.2" |
| 1105 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1164 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1377,6 +1436,23 @@ dependencies = [ | |||
| 1377 | ] | 1436 | ] |
| 1378 | 1437 | ||
| 1379 | [[package]] | 1438 | [[package]] |
| 1439 | name = "hostname" | ||
| 1440 | version = "0.4.2" | ||
| 1441 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1442 | checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" | ||
| 1443 | dependencies = [ | ||
| 1444 | "cfg-if", | ||
| 1445 | "libc", | ||
| 1446 | "windows-link", | ||
| 1447 | ] | ||
| 1448 | |||
| 1449 | [[package]] | ||
| 1450 | name = "httpdate" | ||
| 1451 | version = "1.0.3" | ||
| 1452 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1453 | checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" | ||
| 1454 | |||
| 1455 | [[package]] | ||
| 1380 | name = "hybrid-array" | 1456 | name = "hybrid-array" |
| 1381 | version = "0.4.13" | 1457 | version = "0.4.13" |
| 1382 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1458 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1622,6 +1698,33 @@ dependencies = [ | |||
| 1622 | ] | 1698 | ] |
| 1623 | 1699 | ||
| 1624 | [[package]] | 1700 | [[package]] |
| 1701 | name = "lettre" | ||
| 1702 | version = "0.11.22" | ||
| 1703 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1704 | checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" | ||
| 1705 | dependencies = [ | ||
| 1706 | "async-trait", | ||
| 1707 | "base64", | ||
| 1708 | "email-encoding", | ||
| 1709 | "email_address", | ||
| 1710 | "fastrand", | ||
| 1711 | "futures-io", | ||
| 1712 | "futures-util", | ||
| 1713 | "hostname", | ||
| 1714 | "httpdate", | ||
| 1715 | "idna", | ||
| 1716 | "mime", | ||
| 1717 | "native-tls", | ||
| 1718 | "nom", | ||
| 1719 | "percent-encoding", | ||
| 1720 | "quoted_printable", | ||
| 1721 | "socket2", | ||
| 1722 | "tokio", | ||
| 1723 | "tokio-native-tls", | ||
| 1724 | "url", | ||
| 1725 | ] | ||
| 1726 | |||
| 1727 | [[package]] | ||
| 1625 | name = "libbz2-rs-sys" | 1728 | name = "libbz2-rs-sys" |
| 1626 | version = "0.2.5" | 1729 | version = "0.2.5" |
| 1627 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1730 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1704,6 +1807,12 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" | |||
| 1704 | [[package]] | 1807 | [[package]] |
| 1705 | name = "mail" | 1808 | name = "mail" |
| 1706 | version = "0.1.0" | 1809 | version = "0.1.0" |
| 1810 | dependencies = [ | ||
| 1811 | "config", | ||
| 1812 | "lettre", | ||
| 1813 | "thiserror 2.0.19", | ||
| 1814 | "tokio", | ||
| 1815 | ] | ||
| 1707 | 1816 | ||
| 1708 | [[package]] | 1817 | [[package]] |
| 1709 | name = "md-5" | 1818 | name = "md-5" |
| @@ -1732,6 +1841,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1732 | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" | 1841 | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" |
| 1733 | 1842 | ||
| 1734 | [[package]] | 1843 | [[package]] |
| 1844 | name = "mime" | ||
| 1845 | version = "0.3.17" | ||
| 1846 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1847 | checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" | ||
| 1848 | |||
| 1849 | [[package]] | ||
| 1735 | name = "miniz_oxide" | 1850 | name = "miniz_oxide" |
| 1736 | version = "0.8.9" | 1851 | version = "0.8.9" |
| 1737 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1852 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1777,6 +1892,23 @@ dependencies = [ | |||
| 1777 | ] | 1892 | ] |
| 1778 | 1893 | ||
| 1779 | [[package]] | 1894 | [[package]] |
| 1895 | name = "native-tls" | ||
| 1896 | version = "0.2.18" | ||
| 1897 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1898 | checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" | ||
| 1899 | dependencies = [ | ||
| 1900 | "libc", | ||
| 1901 | "log", | ||
| 1902 | "openssl", | ||
| 1903 | "openssl-probe", | ||
| 1904 | "openssl-sys", | ||
| 1905 | "schannel", | ||
| 1906 | "security-framework", | ||
| 1907 | "security-framework-sys", | ||
| 1908 | "tempfile", | ||
| 1909 | ] | ||
| 1910 | |||
| 1911 | [[package]] | ||
| 1780 | name = "nom" | 1912 | name = "nom" |
| 1781 | version = "8.0.0" | 1913 | version = "8.0.0" |
| 1782 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1914 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1883,6 +2015,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1883 | checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" | 2015 | checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" |
| 1884 | 2016 | ||
| 1885 | [[package]] | 2017 | [[package]] |
| 2018 | name = "openssl" | ||
| 2019 | version = "0.10.81" | ||
| 2020 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2021 | checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" | ||
| 2022 | dependencies = [ | ||
| 2023 | "bitflags", | ||
| 2024 | "cfg-if", | ||
| 2025 | "foreign-types", | ||
| 2026 | "libc", | ||
| 2027 | "openssl-macros", | ||
| 2028 | "openssl-sys", | ||
| 2029 | ] | ||
| 2030 | |||
| 2031 | [[package]] | ||
| 2032 | name = "openssl-macros" | ||
| 2033 | version = "0.1.1" | ||
| 2034 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2035 | checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" | ||
| 2036 | dependencies = [ | ||
| 2037 | "proc-macro2", | ||
| 2038 | "quote", | ||
| 2039 | "syn 2.0.119", | ||
| 2040 | ] | ||
| 2041 | |||
| 2042 | [[package]] | ||
| 2043 | name = "openssl-probe" | ||
| 2044 | version = "0.2.1" | ||
| 2045 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2046 | checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" | ||
| 2047 | |||
| 2048 | [[package]] | ||
| 2049 | name = "openssl-sys" | ||
| 2050 | version = "0.9.117" | ||
| 2051 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2052 | checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" | ||
| 2053 | dependencies = [ | ||
| 2054 | "cc", | ||
| 2055 | "libc", | ||
| 2056 | "pkg-config", | ||
| 2057 | "vcpkg", | ||
| 2058 | ] | ||
| 2059 | |||
| 2060 | [[package]] | ||
| 1886 | name = "p256" | 2061 | name = "p256" |
| 1887 | version = "0.13.2" | 2062 | version = "0.13.2" |
| 1888 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2063 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2178,6 +2353,12 @@ dependencies = [ | |||
| 2178 | ] | 2353 | ] |
| 2179 | 2354 | ||
| 2180 | [[package]] | 2355 | [[package]] |
| 2356 | name = "quoted_printable" | ||
| 2357 | version = "0.5.2" | ||
| 2358 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2359 | checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" | ||
| 2360 | |||
| 2361 | [[package]] | ||
| 2181 | name = "r-efi" | 2362 | name = "r-efi" |
| 2182 | version = "5.3.0" | 2363 | version = "5.3.0" |
| 2183 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2364 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2405,6 +2586,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2405 | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" | 2586 | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" |
| 2406 | 2587 | ||
| 2407 | [[package]] | 2588 | [[package]] |
| 2589 | name = "schannel" | ||
| 2590 | version = "0.1.29" | ||
| 2591 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2592 | checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" | ||
| 2593 | dependencies = [ | ||
| 2594 | "windows-sys 0.61.2", | ||
| 2595 | ] | ||
| 2596 | |||
| 2597 | [[package]] | ||
| 2408 | name = "scopeguard" | 2598 | name = "scopeguard" |
| 2409 | version = "1.2.0" | 2599 | version = "1.2.0" |
| 2410 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2600 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2426,6 +2616,29 @@ dependencies = [ | |||
| 2426 | ] | 2616 | ] |
| 2427 | 2617 | ||
| 2428 | [[package]] | 2618 | [[package]] |
| 2619 | name = "security-framework" | ||
| 2620 | version = "3.7.0" | ||
| 2621 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2622 | checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" | ||
| 2623 | dependencies = [ | ||
| 2624 | "bitflags", | ||
| 2625 | "core-foundation", | ||
| 2626 | "core-foundation-sys", | ||
| 2627 | "libc", | ||
| 2628 | "security-framework-sys", | ||
| 2629 | ] | ||
| 2630 | |||
| 2631 | [[package]] | ||
| 2632 | name = "security-framework-sys" | ||
| 2633 | version = "2.17.0" | ||
| 2634 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2635 | checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" | ||
| 2636 | dependencies = [ | ||
| 2637 | "core-foundation-sys", | ||
| 2638 | "libc", | ||
| 2639 | ] | ||
| 2640 | |||
| 2641 | [[package]] | ||
| 2429 | name = "semver" | 2642 | name = "semver" |
| 2430 | version = "1.0.28" | 2643 | version = "1.0.28" |
| 2431 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2644 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -3069,6 +3282,16 @@ dependencies = [ | |||
| 3069 | ] | 3282 | ] |
| 3070 | 3283 | ||
| 3071 | [[package]] | 3284 | [[package]] |
| 3285 | name = "tokio-native-tls" | ||
| 3286 | version = "0.3.1" | ||
| 3287 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3288 | checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" | ||
| 3289 | dependencies = [ | ||
| 3290 | "native-tls", | ||
| 3291 | "tokio", | ||
| 3292 | ] | ||
| 3293 | |||
| 3294 | [[package]] | ||
| 3072 | name = "tokio-stream" | 3295 | name = "tokio-stream" |
| 3073 | version = "0.1.19" | 3296 | version = "0.1.19" |
| 3074 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3297 | source = "registry+https://github.com/rust-lang/crates.io-index" |
Cargo.toml +10 −0
| @@ -101,6 +101,16 @@ insta = "1" | |||
| 101 | # is LGPL, which our license gate forbids (see docs/decisions.md). | 101 | # is LGPL, which our license gate forbids (see docs/decisions.md). |
| 102 | ssh-key = { version = "0.6.7", features = ["ed25519", "rsa", "p256", "std"] } | 102 | ssh-key = { version = "0.6.7", features = ["ed25519", "rsa", "p256", "std"] } |
| 103 | pgp = "0.20" | 103 | pgp = "0.20" |
| 104 | # SMTP mail. native-tls (system OpenSSL, already a build input) rather than the | ||
| 105 | # spec's rustls transport, which pulls `ring` and would fail the license gate — | ||
| 106 | # see docs/decisions.md. | ||
| 107 | lettre = { version = "0.11", default-features = false, features = [ | ||
| 108 | "builder", | ||
| 109 | "smtp-transport", | ||
| 110 | "tokio1", | ||
| 111 | "tokio1-native-tls", | ||
| 112 | "hostname", | ||
| 113 | ] } | ||
| 104 | 114 | ||
| 105 | [workspace.lints.rust] | 115 | [workspace.lints.rust] |
| 106 | unsafe_code = "forbid" | 116 | unsafe_code = "forbid" |
crates/cli/Cargo.toml +1 −0
| @@ -14,6 +14,7 @@ config = { workspace = true } | |||
| 14 | store = { workspace = true } | 14 | store = { workspace = true } |
| 15 | auth = { workspace = true } | 15 | auth = { workspace = true } |
| 16 | git = { workspace = true } | 16 | git = { workspace = true } |
| 17 | mail = { workspace = true } | ||
| 17 | model = { workspace = true } | 18 | model = { workspace = true } |
| 18 | anyhow = { workspace = true } | 19 | anyhow = { workspace = true } |
| 19 | clap = { workspace = true } | 20 | clap = { workspace = true } |
crates/cli/src/user_cmd.rs +56 −9
| @@ -10,7 +10,7 @@ use std::process::ExitCode; | |||
| 10 | use clap::Subcommand; | 10 | use clap::Subcommand; |
| 11 | use config::Config; | 11 | use config::Config; |
| 12 | use model::User; | 12 | use model::User; |
| 13 | use store::{NewUser, Store}; | 13 | use store::{NewInvite, NewUser, Store}; |
| 14 | 14 | ||
| 15 | use crate::context::{block_on, open_store}; | 15 | use crate::context::{block_on, open_store}; |
| 16 | use crate::prompt::{confirm, resolve_password}; | 16 | use crate::prompt::{confirm, resolve_password}; |
| @@ -160,18 +160,65 @@ async fn add( | |||
| 160 | if user.is_admin { " (admin)" } else { "" } | 160 | if user.is_admin { " (admin)" } else { "" } |
| 161 | ); | 161 | ); |
| 162 | if !has_password { | 162 | if !has_password { |
| 163 | // Email invitations are not implemented yet (phase 9), so there is no | 163 | // No password was set, so mint an activation invite, try to email it, and |
| 164 | // out-of-band way to activate the account — point the operator at the | 164 | // always print the link so an operator can deliver it when SMTP is off. |
| 165 | // direct path. This is the SMTP-disabled fallback made explicit. | 165 | send_activation(&config, &store, &user).await?; |
| 166 | eprintln!( | ||
| 167 | "note: no password set; this account cannot log in yet. \ | ||
| 168 | Set one with: fabrica user passwd {}", | ||
| 169 | user.username | ||
| 170 | ); | ||
| 171 | } | 166 | } |
| 172 | Ok(ExitCode::SUCCESS) | 167 | Ok(ExitCode::SUCCESS) |
| 173 | } | 168 | } |
| 174 | 169 | ||
| 170 | /// Mint an activation invite for `user`, attempt to email it (best-effort), and | ||
| 171 | /// print the activation URL to stdout regardless. | ||
| 172 | async fn send_activation(config: &Config, store: &Store, user: &User) -> anyhow::Result<()> { | ||
| 173 | // The emailed token is opaque 32-byte randomness; only its SHA-256 is stored. | ||
| 174 | let token = auth::new_session_token(); | ||
| 175 | let ttl_ms = i64::from(config.auth.invite_ttl_hours).saturating_mul(3_600_000); | ||
| 176 | store | ||
| 177 | .create_invite(NewInvite { | ||
| 178 | user_id: user.id.clone(), | ||
| 179 | token_hash: token.id, | ||
| 180 | purpose: mail::Purpose::Activate.as_str().to_string(), | ||
| 181 | expires_at: now_ms().saturating_add(ttl_ms), | ||
| 182 | }) | ||
| 183 | .await?; | ||
| 184 | |||
| 185 | let link = format!( | ||
| 186 | "{}/invite/{}", | ||
| 187 | config.instance.url.trim_end_matches('/'), | ||
| 188 | token.cookie | ||
| 189 | ); | ||
| 190 | |||
| 191 | match mail::Mailer::build(&config.mail) { | ||
| 192 | Ok(mailer) => match mailer | ||
| 193 | .send_invite( | ||
| 194 | &config.instance.name, | ||
| 195 | &user.email, | ||
| 196 | user.display_name.as_deref(), | ||
| 197 | &link, | ||
| 198 | mail::Purpose::Activate, | ||
| 199 | ) | ||
| 200 | .await | ||
| 201 | { | ||
| 202 | Ok(()) => println!("sent activation email to {}", user.email), | ||
| 203 | Err(err) => eprintln!("warning: could not send activation email: {err}"), | ||
| 204 | }, | ||
| 205 | Err(err) => eprintln!("warning: mail not configured: {err}"), | ||
| 206 | } | ||
| 207 | println!( | ||
| 208 | "activation link (valid {}h): {link}", | ||
| 209 | config.auth.invite_ttl_hours | ||
| 210 | ); | ||
| 211 | Ok(()) | ||
| 212 | } | ||
| 213 | |||
| 214 | /// The current time in Unix milliseconds. | ||
| 215 | fn now_ms() -> i64 { | ||
| 216 | use std::time::{SystemTime, UNIX_EPOCH}; | ||
| 217 | SystemTime::now() | ||
| 218 | .duration_since(UNIX_EPOCH) | ||
| 219 | .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX)) | ||
| 220 | } | ||
| 221 | |||
| 175 | async fn passwd( | 222 | async fn passwd( |
| 176 | config_path: Option<&Path>, | 223 | config_path: Option<&Path>, |
| 177 | name: &str, | 224 | name: &str, |
crates/config/src/types.rs +3 −0
| @@ -181,6 +181,8 @@ pub struct Auth { | |||
| 181 | pub session_ttl_days: u32, | 181 | pub session_ttl_days: u32, |
| 182 | /// API token lifetime, in days. | 182 | /// API token lifetime, in days. |
| 183 | pub token_ttl_days: u32, | 183 | pub token_ttl_days: u32, |
| 184 | /// Activation/reset invite lifetime, in hours. | ||
| 185 | pub invite_ttl_hours: u32, | ||
| 184 | /// Name of the session cookie. | 186 | /// Name of the session cookie. |
| 185 | pub cookie_name: String, | 187 | pub cookie_name: String, |
| 186 | /// Set the `Secure` attribute on the session cookie. | 188 | /// Set the `Secure` attribute on the session cookie. |
| @@ -200,6 +202,7 @@ impl Default for Auth { | |||
| 200 | secret_file: None, | 202 | secret_file: None, |
| 201 | session_ttl_days: 30, | 203 | session_ttl_days: 30, |
| 202 | token_ttl_days: 90, | 204 | token_ttl_days: 90, |
| 205 | invite_ttl_hours: 72, | ||
| 203 | cookie_name: "fabrica_session".to_string(), | 206 | cookie_name: "fabrica_session".to_string(), |
| 204 | cookie_secure: true, | 207 | cookie_secure: true, |
| 205 | argon2_m_cost: 19456, | 208 | argon2_m_cost: 19456, |
crates/mail/Cargo.toml +6 −0
| @@ -10,6 +10,12 @@ repository.workspace = true | |||
| 10 | publish.workspace = true | 10 | publish.workspace = true |
| 11 | 11 | ||
| 12 | [dependencies] | 12 | [dependencies] |
| 13 | config = { workspace = true } | ||
| 14 | lettre = { workspace = true } | ||
| 15 | thiserror = { workspace = true } | ||
| 16 | |||
| 17 | [dev-dependencies] | ||
| 18 | tokio = { workspace = true } | ||
| 13 | 19 | ||
| 14 | [lints] | 20 | [lints] |
| 15 | workspace = true | 21 | workspace = true |
crates/mail/src/lib.rs +223 −1
| @@ -2,4 +2,226 @@ | |||
| 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this | 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this |
| 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. |
| 4 | 4 | ||
| 5 | //! Outbound mail: templates rendered to text and HTML, sent best-effort. | 5 | //! Outbound mail: SMTP delivery plus the activation/reset templates. |
| 6 | //! | ||
| 7 | //! Three backends (`config::MailBackend`): `smtp` delivers over SMTP with | ||
| 8 | //! native-TLS (`starttls`/`tls`/plaintext), `stdout` prints the rendered message | ||
| 9 | //! (development), and `none` rejects any flow that needs mail. Sending is | ||
| 10 | //! best-effort — the caller always has the activation URL to deliver by hand — so | ||
| 11 | //! `send_invite` returns a `Result` the caller logs rather than fails on. | ||
| 12 | |||
| 13 | mod templates; | ||
| 14 | |||
| 15 | use std::time::Duration; | ||
| 16 | |||
| 17 | use config::{Mail, MailBackend, MailEncryption}; | ||
| 18 | use lettre::message::{Mailbox, MultiPart}; | ||
| 19 | use lettre::transport::smtp::authentication::Credentials; | ||
| 20 | use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; | ||
| 21 | |||
| 22 | pub use crate::templates::{Purpose, Rendered, render}; | ||
| 23 | |||
| 24 | /// An error from the mail layer. | ||
| 25 | #[derive(Debug, thiserror::Error)] | ||
| 26 | pub enum MailError { | ||
| 27 | /// The `none` backend was asked to send. | ||
| 28 | #[error("mail is disabled (mail.backend = \"none\")")] | ||
| 29 | Disabled, | ||
| 30 | /// An address failed to parse. | ||
| 31 | #[error("invalid email address: {0}")] | ||
| 32 | Address(String), | ||
| 33 | /// Building the SMTP transport failed. | ||
| 34 | #[error("smtp transport setup failed: {0}")] | ||
| 35 | Transport(String), | ||
| 36 | /// Building the message failed. | ||
| 37 | #[error("message construction failed: {0}")] | ||
| 38 | Message(String), | ||
| 39 | /// Delivery failed. | ||
| 40 | #[error("mail delivery failed: {0}")] | ||
| 41 | Send(String), | ||
| 42 | } | ||
| 43 | |||
| 44 | /// A configured mailer. Cheap to build once and reuse. | ||
| 45 | pub struct Mailer { | ||
| 46 | from: Mailbox, | ||
| 47 | backend: Delivery, | ||
| 48 | } | ||
| 49 | |||
| 50 | /// The delivery mechanism behind a [`Mailer`]. | ||
| 51 | enum Delivery { | ||
| 52 | /// Deliver over SMTP. | ||
| 53 | Smtp(Box<AsyncSmtpTransport<Tokio1Executor>>), | ||
| 54 | /// Print the rendered message to stdout. | ||
| 55 | Stdout, | ||
| 56 | /// Reject sends. | ||
| 57 | None, | ||
| 58 | } | ||
| 59 | |||
| 60 | impl Mailer { | ||
| 61 | /// Build a mailer from configuration. | ||
| 62 | /// | ||
| 63 | /// # Errors | ||
| 64 | /// | ||
| 65 | /// Returns [`MailError::Address`] if `mail.from` is not a valid address, or | ||
| 66 | /// [`MailError::Transport`] if the SMTP transport cannot be constructed. | ||
| 67 | pub fn build(config: &Mail) -> Result<Self, MailError> { | ||
| 68 | let from: Mailbox = config | ||
| 69 | .from | ||
| 70 | .parse() | ||
| 71 | .map_err(|e| MailError::Address(format!("{}: {e}", config.from)))?; | ||
| 72 | |||
| 73 | let backend = match config.backend { | ||
| 74 | MailBackend::Stdout => Delivery::Stdout, | ||
| 75 | MailBackend::None => Delivery::None, | ||
| 76 | MailBackend::Smtp => Delivery::Smtp(Box::new(build_transport(config)?)), | ||
| 77 | }; | ||
| 78 | Ok(Self { from, backend }) | ||
| 79 | } | ||
| 80 | |||
| 81 | /// Send an invite email to `to_email` (optionally named `to_name`) with the | ||
| 82 | /// activation `link`. | ||
| 83 | /// | ||
| 84 | /// # Errors | ||
| 85 | /// | ||
| 86 | /// Returns [`MailError`] on address/message/transport failure, or | ||
| 87 | /// [`MailError::Disabled`] under the `none` backend. Callers treat this as | ||
| 88 | /// best-effort and still surface the link out of band. | ||
| 89 | pub async fn send_invite( | ||
| 90 | &self, | ||
| 91 | instance_name: &str, | ||
| 92 | to_email: &str, | ||
| 93 | to_name: Option<&str>, | ||
| 94 | link: &str, | ||
| 95 | purpose: Purpose, | ||
| 96 | ) -> Result<(), MailError> { | ||
| 97 | let rendered = render(instance_name, link, purpose); | ||
| 98 | let to = mailbox(to_email, to_name)?; | ||
| 99 | |||
| 100 | match &self.backend { | ||
| 101 | Delivery::None => Err(MailError::Disabled), | ||
| 102 | Delivery::Stdout => { | ||
| 103 | println!("--- mail ({}) ---", purpose.as_str()); | ||
| 104 | println!("From: {}", self.from); | ||
| 105 | println!("To: {to}"); | ||
| 106 | println!("Subject: {}", rendered.subject); | ||
| 107 | println!(); | ||
| 108 | println!("{}", rendered.plain); | ||
| 109 | println!("--- end mail ---"); | ||
| 110 | Ok(()) | ||
| 111 | } | ||
| 112 | Delivery::Smtp(transport) => { | ||
| 113 | let email = Message::builder() | ||
| 114 | .from(self.from.clone()) | ||
| 115 | .to(to) | ||
| 116 | .subject(rendered.subject) | ||
| 117 | .multipart(MultiPart::alternative_plain_html( | ||
| 118 | rendered.plain, | ||
| 119 | rendered.html, | ||
| 120 | )) | ||
| 121 | .map_err(|e| MailError::Message(e.to_string()))?; | ||
| 122 | transport | ||
| 123 | .send(email) | ||
| 124 | .await | ||
| 125 | .map_err(|e| MailError::Send(e.to_string()))?; | ||
| 126 | Ok(()) | ||
| 127 | } | ||
| 128 | } | ||
| 129 | } | ||
| 130 | } | ||
| 131 | |||
| 132 | /// Build a `to` mailbox from an address and optional display name. | ||
| 133 | fn mailbox(email: &str, name: Option<&str>) -> Result<Mailbox, MailError> { | ||
| 134 | let address = email | ||
| 135 | .parse() | ||
| 136 | .map_err(|e| MailError::Address(format!("{email}: {e}")))?; | ||
| 137 | Ok(Mailbox::new(name.map(str::to_string), address)) | ||
| 138 | } | ||
| 139 | |||
| 140 | /// Construct the async SMTP transport for the `smtp` backend. | ||
| 141 | fn build_transport(config: &Mail) -> Result<AsyncSmtpTransport<Tokio1Executor>, MailError> { | ||
| 142 | let builder = match config.encryption { | ||
| 143 | MailEncryption::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host) | ||
| 144 | .map_err(|e| MailError::Transport(e.to_string()))?, | ||
| 145 | MailEncryption::Starttls => { | ||
| 146 | AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.host) | ||
| 147 | .map_err(|e| MailError::Transport(e.to_string()))? | ||
| 148 | } | ||
| 149 | MailEncryption::None => { | ||
| 150 | AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.host) | ||
| 151 | } | ||
| 152 | }; | ||
| 153 | |||
| 154 | let mut builder = builder | ||
| 155 | .port(config.port) | ||
| 156 | .timeout(Some(Duration::from_secs(config.timeout_secs))); | ||
| 157 | if !config.username.is_empty() { | ||
| 158 | let password = config | ||
| 159 | .password | ||
| 160 | .as_ref() | ||
| 161 | .map(|s| s.expose().to_string()) | ||
| 162 | .unwrap_or_default(); | ||
| 163 | builder = builder.credentials(Credentials::new(config.username.clone(), password)); | ||
| 164 | } | ||
| 165 | Ok(builder.build()) | ||
| 166 | } | ||
| 167 | |||
| 168 | #[cfg(test)] | ||
| 169 | mod tests { | ||
| 170 | #![allow(clippy::unwrap_used)] | ||
| 171 | |||
| 172 | use config::Mail; | ||
| 173 | |||
| 174 | use super::*; | ||
| 175 | |||
| 176 | fn stdout_config() -> Mail { | ||
| 177 | Mail { | ||
| 178 | backend: MailBackend::Stdout, | ||
| 179 | from: "Fabrica <fabrica@example.com>".to_string(), | ||
| 180 | ..Mail::default() | ||
| 181 | } | ||
| 182 | } | ||
| 183 | |||
| 184 | #[tokio::test] | ||
| 185 | async fn stdout_backend_sends_without_error() { | ||
| 186 | let mailer = Mailer::build(&stdout_config()).unwrap(); | ||
| 187 | mailer | ||
| 188 | .send_invite( | ||
| 189 | "Forge", | ||
| 190 | "ada@example.com", | ||
| 191 | Some("Ada"), | ||
| 192 | "https://git.example.com/invite/tok", | ||
| 193 | Purpose::Activate, | ||
| 194 | ) | ||
| 195 | .await | ||
| 196 | .unwrap(); | ||
| 197 | } | ||
| 198 | |||
| 199 | #[tokio::test] | ||
| 200 | async fn none_backend_rejects() { | ||
| 201 | let config = Mail { | ||
| 202 | backend: MailBackend::None, | ||
| 203 | ..stdout_config() | ||
| 204 | }; | ||
| 205 | let mailer = Mailer::build(&config).unwrap(); | ||
| 206 | let err = mailer | ||
| 207 | .send_invite( | ||
| 208 | "Forge", | ||
| 209 | "a@b.com", | ||
| 210 | None, | ||
| 211 | "https://x/invite/t", | ||
| 212 | Purpose::Activate, | ||
| 213 | ) | ||
| 214 | .await | ||
| 215 | .unwrap_err(); | ||
| 216 | assert!(matches!(err, MailError::Disabled)); | ||
| 217 | } | ||
| 218 | |||
| 219 | #[test] | ||
| 220 | fn invalid_from_is_rejected() { | ||
| 221 | let config = Mail { | ||
| 222 | from: "not an address".to_string(), | ||
| 223 | ..stdout_config() | ||
| 224 | }; | ||
| 225 | assert!(matches!(Mailer::build(&config), Err(MailError::Address(_)))); | ||
| 226 | } | ||
| 227 | } | ||
crates/mail/src/templates.rs +117 −0
| @@ -0,0 +1,117 @@ | |||
| 1 | // This Source Code Form is subject to the terms of the Mozilla Public | ||
| 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | ||
| 4 | |||
| 5 | //! Message templates. A plain-text primary and a minimal HTML alternative are | ||
| 6 | //! rendered from the same data, with the instance name and URL interpolated. | ||
| 7 | |||
| 8 | /// Why an invite was sent. | ||
| 9 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 10 | pub enum Purpose { | ||
| 11 | /// A new-account activation link. | ||
| 12 | Activate, | ||
| 13 | /// A password-reset link. | ||
| 14 | Reset, | ||
| 15 | } | ||
| 16 | |||
| 17 | impl Purpose { | ||
| 18 | /// The `invites.purpose` token. | ||
| 19 | #[must_use] | ||
| 20 | pub fn as_str(self) -> &'static str { | ||
| 21 | match self { | ||
| 22 | Self::Activate => "activate", | ||
| 23 | Self::Reset => "reset", | ||
| 24 | } | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | /// A rendered message: subject plus the two body alternatives. | ||
| 29 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 30 | pub struct Rendered { | ||
| 31 | /// The `Subject:` header. | ||
| 32 | pub subject: String, | ||
| 33 | /// The plain-text body (primary). | ||
| 34 | pub plain: String, | ||
| 35 | /// The HTML body (alternative). | ||
| 36 | pub html: String, | ||
| 37 | } | ||
| 38 | |||
| 39 | /// Render the invite message for `purpose`, interpolating the instance name/URL | ||
| 40 | /// and the activation `link`. | ||
| 41 | #[must_use] | ||
| 42 | pub fn render(instance_name: &str, link: &str, purpose: Purpose) -> Rendered { | ||
| 43 | let (subject, lead, action) = match purpose { | ||
| 44 | Purpose::Activate => ( | ||
| 45 | format!("Activate your {instance_name} account"), | ||
| 46 | format!("An account has been created for you on {instance_name}."), | ||
| 47 | "set your password and activate your account", | ||
| 48 | ), | ||
| 49 | Purpose::Reset => ( | ||
| 50 | format!("Reset your {instance_name} password"), | ||
| 51 | format!("A password reset was requested for your {instance_name} account."), | ||
| 52 | "choose a new password", | ||
| 53 | ), | ||
| 54 | }; | ||
| 55 | |||
| 56 | let plain = format!( | ||
| 57 | "{lead}\n\nOpen this link to {action}:\n\n {link}\n\n\ | ||
| 58 | If you did not expect this email, you can safely ignore it.\n" | ||
| 59 | ); | ||
| 60 | let html = format!( | ||
| 61 | "<p>{}</p>\n<p>Open this link to {}:</p>\n<p><a href=\"{}\">{}</a></p>\n\ | ||
| 62 | <p>If you did not expect this email, you can safely ignore it.</p>\n", | ||
| 63 | escape(&lead), | ||
| 64 | escape(action), | ||
| 65 | escape(link), | ||
| 66 | escape(link), | ||
| 67 | ); | ||
| 68 | |||
| 69 | Rendered { | ||
| 70 | subject, | ||
| 71 | plain, | ||
| 72 | html, | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | /// Minimal HTML escaping for the interpolated values. | ||
| 77 | fn escape(s: &str) -> String { | ||
| 78 | s.replace('&', "&") | ||
| 79 | .replace('<', "<") | ||
| 80 | .replace('>', ">") | ||
| 81 | .replace('"', """) | ||
| 82 | } | ||
| 83 | |||
| 84 | #[cfg(test)] | ||
| 85 | mod tests { | ||
| 86 | use super::*; | ||
| 87 | |||
| 88 | #[test] | ||
| 89 | fn activate_body_contains_link_and_name() { | ||
| 90 | let r = render( | ||
| 91 | "Forge", | ||
| 92 | "https://git.example.com/invite/abc123", | ||
| 93 | Purpose::Activate, | ||
| 94 | ); | ||
| 95 | assert!(r.subject.contains("Forge")); | ||
| 96 | assert!(r.plain.contains("https://git.example.com/invite/abc123")); | ||
| 97 | assert!( | ||
| 98 | r.html | ||
| 99 | .contains("href=\"https://git.example.com/invite/abc123\"") | ||
| 100 | ); | ||
| 101 | assert!(r.plain.contains("activate your account")); | ||
| 102 | } | ||
| 103 | |||
| 104 | #[test] | ||
| 105 | fn reset_uses_reset_wording() { | ||
| 106 | let r = render("Forge", "https://x/invite/t", Purpose::Reset); | ||
| 107 | assert!(r.subject.to_lowercase().contains("reset")); | ||
| 108 | assert!(r.plain.contains("new password")); | ||
| 109 | } | ||
| 110 | |||
| 111 | #[test] | ||
| 112 | fn html_escapes_interpolated_values() { | ||
| 113 | let r = render("A&B", "https://x/?a=1&b=2", Purpose::Activate); | ||
| 114 | assert!(r.html.contains("&")); | ||
| 115 | assert!(!r.html.contains("a=1&b=2"), "raw ampersand must be escaped"); | ||
| 116 | } | ||
| 117 | } | ||
crates/model/src/entity.rs +20 −0
| @@ -79,6 +79,26 @@ pub struct User { | |||
| 79 | pub updated_at: Timestamp, | 79 | pub updated_at: Timestamp, |
| 80 | } | 80 | } |
| 81 | 81 | ||
| 82 | /// A pending activation or password-reset invite. The emailed token is never | ||
| 83 | /// stored; `token_hash` is its SHA-256, matched on redemption. | ||
| 84 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 85 | pub struct Invite { | ||
| 86 | /// ULID primary key. | ||
| 87 | pub id: String, | ||
| 88 | /// The user this invite activates. | ||
| 89 | pub user_id: String, | ||
| 90 | /// SHA-256 (hex) of the emailed token. | ||
| 91 | pub token_hash: String, | ||
| 92 | /// `"activate"` or `"reset"`. | ||
| 93 | pub purpose: String, | ||
| 94 | /// When the invite expires. | ||
| 95 | pub expires_at: Timestamp, | ||
| 96 | /// When it was redeemed, if it has been (single-use). | ||
| 97 | pub used_at: Option<Timestamp>, | ||
| 98 | /// Creation time. | ||
| 99 | pub created_at: Timestamp, | ||
| 100 | } | ||
| 101 | |||
| 82 | /// A nested, user-owned group. Groups provide the repository hierarchy; `path` is | 102 | /// A nested, user-owned group. Groups provide the repository hierarchy; `path` is |
| 83 | /// the materialized `group/sub/leaf` under the owner. | 103 | /// the materialized `group/sub/leaf` under the owner. |
| 84 | #[derive(Debug, Clone, PartialEq, Eq)] | 104 | #[derive(Debug, Clone, PartialEq, Eq)] |
crates/model/src/lib.rs +1 −1
| @@ -11,5 +11,5 @@ | |||
| 11 | pub mod entity; | 11 | pub mod entity; |
| 12 | pub mod name; | 12 | pub mod name; |
| 13 | 13 | ||
| 14 | pub use entity::{ApiToken, Group, Key, KeyKind, Repo, Timestamp, User}; | 14 | pub use entity::{ApiToken, Group, Invite, Key, KeyKind, Repo, Timestamp, User}; |
| 15 | pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name}; | 15 | pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name}; |
crates/store/src/invites.rs +113 −0
| @@ -0,0 +1,113 @@ | |||
| 1 | // This Source Code Form is subject to the terms of the Mozilla Public | ||
| 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | ||
| 4 | |||
| 5 | //! Activation and password-reset invites. | ||
| 6 | //! | ||
| 7 | //! The emailed token is never stored; only its SHA-256 (`token_hash`) is, and | ||
| 8 | //! redemption looks the invite up by that hash. Invites are single-use | ||
| 9 | //! (`used_at`) and time-limited (`expires_at`). | ||
| 10 | |||
| 11 | use model::Invite; | ||
| 12 | use sqlx::any::AnyRow; | ||
| 13 | use sqlx::{AssertSqlSafe, Row}; | ||
| 14 | |||
| 15 | use crate::{Store, StoreError, new_id, now_ms}; | ||
| 16 | |||
| 17 | /// The columns of `invites` in a fixed order, shared by every `SELECT`. | ||
| 18 | const INVITE_COLUMNS: &str = "id, user_id, token_hash, purpose, expires_at, used_at, created_at"; | ||
| 19 | |||
| 20 | /// Fields needed to mint an invite. The caller generates the token and passes its | ||
| 21 | /// SHA-256 here; the store assigns the id and creation time. | ||
| 22 | #[derive(Debug, Clone)] | ||
| 23 | pub struct NewInvite { | ||
| 24 | /// The user the invite activates. | ||
| 25 | pub user_id: String, | ||
| 26 | /// SHA-256 (hex) of the emailed token. | ||
| 27 | pub token_hash: String, | ||
| 28 | /// `"activate"` or `"reset"`. | ||
| 29 | pub purpose: String, | ||
| 30 | /// Absolute expiry, epoch ms. | ||
| 31 | pub expires_at: i64, | ||
| 32 | } | ||
| 33 | |||
| 34 | impl Store { | ||
| 35 | /// Create an invite row. | ||
| 36 | /// | ||
| 37 | /// # Errors | ||
| 38 | /// | ||
| 39 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 40 | pub async fn create_invite(&self, new: NewInvite) -> Result<Invite, StoreError> { | ||
| 41 | let invite = Invite { | ||
| 42 | id: new_id(), | ||
| 43 | user_id: new.user_id, | ||
| 44 | token_hash: new.token_hash, | ||
| 45 | purpose: new.purpose, | ||
| 46 | expires_at: new.expires_at, | ||
| 47 | used_at: None, | ||
| 48 | created_at: now_ms(), | ||
| 49 | }; | ||
| 50 | let sql = "INSERT INTO invites (id, user_id, token_hash, purpose, expires_at, used_at, created_at) \ | ||
| 51 | VALUES ($1, $2, $3, $4, $5, $6, $7)"; | ||
| 52 | sqlx::query(sql) | ||
| 53 | .bind(invite.id.clone()) | ||
| 54 | .bind(invite.user_id.clone()) | ||
| 55 | .bind(invite.token_hash.clone()) | ||
| 56 | .bind(invite.purpose.clone()) | ||
| 57 | .bind(invite.expires_at) | ||
| 58 | .bind(invite.used_at) | ||
| 59 | .bind(invite.created_at) | ||
| 60 | .execute(&self.pool) | ||
| 61 | .await?; | ||
| 62 | Ok(invite) | ||
| 63 | } | ||
| 64 | |||
| 65 | /// Look up an invite by its token hash, regardless of state. The caller checks | ||
| 66 | /// `used_at`/`expires_at` so it can distinguish "already used" from "expired" | ||
| 67 | /// from "unknown" for the redemption page. | ||
| 68 | /// | ||
| 69 | /// # Errors | ||
| 70 | /// | ||
| 71 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 72 | pub async fn invite_by_token_hash( | ||
| 73 | &self, | ||
| 74 | token_hash: &str, | ||
| 75 | ) -> Result<Option<Invite>, StoreError> { | ||
| 76 | let sql = format!("SELECT {INVITE_COLUMNS} FROM invites WHERE token_hash = $1"); | ||
| 77 | let row = sqlx::query(AssertSqlSafe(sql)) | ||
| 78 | .bind(token_hash) | ||
| 79 | .fetch_optional(&self.pool) | ||
| 80 | .await?; | ||
| 81 | Ok(row.as_ref().map(map_invite).transpose()?) | ||
| 82 | } | ||
| 83 | |||
| 84 | /// Mark an invite redeemed. Guards against a double-redeem with a | ||
| 85 | /// `used_at IS NULL` predicate, so `true` means "this call redeemed it". | ||
| 86 | /// | ||
| 87 | /// # Errors | ||
| 88 | /// | ||
| 89 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 90 | pub async fn mark_invite_used(&self, invite_id: &str) -> Result<bool, StoreError> { | ||
| 91 | let updated = | ||
| 92 | sqlx::query("UPDATE invites SET used_at = $1 WHERE id = $2 AND used_at IS NULL") | ||
| 93 | .bind(now_ms()) | ||
| 94 | .bind(invite_id) | ||
| 95 | .execute(&self.pool) | ||
| 96 | .await? | ||
| 97 | .rows_affected(); | ||
| 98 | Ok(updated > 0) | ||
| 99 | } | ||
| 100 | } | ||
| 101 | |||
| 102 | /// Map an `invites` row (selected via [`INVITE_COLUMNS`]) to an [`Invite`]. | ||
| 103 | fn map_invite(row: &AnyRow) -> Result<Invite, sqlx::Error> { | ||
| 104 | Ok(Invite { | ||
| 105 | id: row.try_get("id")?, | ||
| 106 | user_id: row.try_get("user_id")?, | ||
| 107 | token_hash: row.try_get("token_hash")?, | ||
| 108 | purpose: row.try_get("purpose")?, | ||
| 109 | expires_at: row.try_get("expires_at")?, | ||
| 110 | used_at: row.try_get("used_at")?, | ||
| 111 | created_at: row.try_get("created_at")?, | ||
| 112 | }) | ||
| 113 | } | ||
crates/store/src/lib.rs +2 −0
| @@ -26,6 +26,7 @@ | |||
| 26 | //! set is embedded at build time and selected at runtime by [`Store::migrate`]. | 26 | //! set is embedded at build time and selected at runtime by [`Store::migrate`]. |
| 27 | 27 | ||
| 28 | mod groups; | 28 | mod groups; |
| 29 | mod invites; | ||
| 29 | mod keys; | 30 | mod keys; |
| 30 | mod repos; | 31 | mod repos; |
| 31 | mod tokens; | 32 | mod tokens; |
| @@ -39,6 +40,7 @@ use sqlx::Row; | |||
| 39 | use sqlx::any::{AnyPoolOptions, AnyRow}; | 40 | use sqlx::any::{AnyPoolOptions, AnyRow}; |
| 40 | use sqlx::migrate::Migrator; | 41 | use sqlx::migrate::Migrator; |
| 41 | 42 | ||
| 43 | pub use crate::invites::NewInvite; | ||
| 42 | pub use crate::keys::NewKey; | 44 | pub use crate::keys::NewKey; |
| 43 | pub use crate::repos::NewRepo; | 45 | pub use crate::repos::NewRepo; |
| 44 | pub use crate::tokens::NewToken; | 46 | pub use crate::tokens::NewToken; |
crates/store/src/tests.rs +53 −0
| @@ -732,6 +732,59 @@ async fn rename_and_delete_repo() { | |||
| 732 | } | 732 | } |
| 733 | } | 733 | } |
| 734 | 734 | ||
| 735 | #[tokio::test] | ||
| 736 | async fn invites_round_trip_and_are_single_use() { | ||
| 737 | use super::NewInvite; | ||
| 738 | |||
| 739 | for fx in sqlite_fixtures().await { | ||
| 740 | let user = fx | ||
| 741 | .store | ||
| 742 | .create_user(sample_user("i", "i@example.com")) | ||
| 743 | .await | ||
| 744 | .unwrap(); | ||
| 745 | |||
| 746 | let invite = fx | ||
| 747 | .store | ||
| 748 | .create_invite(NewInvite { | ||
| 749 | user_id: user.id.clone(), | ||
| 750 | token_hash: "hash-abc".to_string(), | ||
| 751 | purpose: "activate".to_string(), | ||
| 752 | expires_at: now_plus_a_day(), | ||
| 753 | }) | ||
| 754 | .await | ||
| 755 | .unwrap(); | ||
| 756 | assert!(invite.used_at.is_none()); | ||
| 757 | |||
| 758 | let found = fx | ||
| 759 | .store | ||
| 760 | .invite_by_token_hash("hash-abc") | ||
| 761 | .await | ||
| 762 | .unwrap() | ||
| 763 | .unwrap(); | ||
| 764 | assert_eq!(found.user_id, user.id); | ||
| 765 | assert_eq!(found.purpose, "activate"); | ||
| 766 | |||
| 767 | // Redemption is single-use: the first mark succeeds, the second does not. | ||
| 768 | assert!(fx.store.mark_invite_used(&invite.id).await.unwrap()); | ||
| 769 | assert!(!fx.store.mark_invite_used(&invite.id).await.unwrap()); | ||
| 770 | let redeemed = fx | ||
| 771 | .store | ||
| 772 | .invite_by_token_hash("hash-abc") | ||
| 773 | .await | ||
| 774 | .unwrap() | ||
| 775 | .unwrap(); | ||
| 776 | assert!(redeemed.used_at.is_some()); | ||
| 777 | |||
| 778 | assert!( | ||
| 779 | fx.store | ||
| 780 | .invite_by_token_hash("nope") | ||
| 781 | .await | ||
| 782 | .unwrap() | ||
| 783 | .is_none() | ||
| 784 | ); | ||
| 785 | } | ||
| 786 | } | ||
| 787 | |||
| 735 | /// Postgres round-trips, mirroring the SQLite ones. Ignored unless the | 788 | /// Postgres round-trips, mirroring the SQLite ones. Ignored unless the |
| 736 | /// `postgres-tests` feature is on and `FABRICA_TEST_POSTGRES_URL` points at a | 789 | /// `postgres-tests` feature is on and `FABRICA_TEST_POSTGRES_URL` points at a |
| 737 | /// reachable database; the test cleans up the rows it creates so it can rerun. | 790 | /// reachable database; the test cleans up the rows it creates so it can rerun. |
docs/decisions.md +17 −0
| @@ -393,3 +393,20 @@ directly on the data directory with no running server, so first-run generation m | |||
| 393 | happen there too. Centralizing it in one helper means `token add` today and `serve` | 393 | happen there too. Centralizing it in one helper means `token add` today and `serve` |
| 394 | later sign with the same key. `auth::new_secret` provides the 32-byte base64url | 394 | later sign with the same key. `auth::new_secret` provides the 32-byte base64url |
| 395 | value, keeping randomness in the auth crate. | 395 | value, keeping randomness in the auth crate. |
| 396 | |||
| 397 | ## 2026-07-24 — Mail over native-TLS, not the spec's rustls transport | ||
| 398 | |||
| 399 | **Decision:** `crates/mail` uses `lettre` with the `tokio1-native-tls` transport, | ||
| 400 | not the `tokio1-rustls` transport the spec names. | ||
| 401 | |||
| 402 | **Alternatives:** `tokio1-rustls-tls` (the spec's choice). | ||
| 403 | |||
| 404 | **Rationale:** lettre's rustls transport pulls `rustls` → `ring`, whose license set | ||
| 405 | is outside `deny.toml`'s allow-list — the same constraint that shaped the JWT and | ||
| 406 | sqlx-TLS decisions. native-tls links the **system OpenSSL**, which is already a | ||
| 407 | flake `buildInput` (and present in the Debian runtime image), so it adds no new C | ||
| 408 | dependency and keeps `ring` out of the graph. Backends are `smtp` (native-TLS | ||
| 409 | `starttls`/`tls`/plaintext), `stdout`, and `none`; tests exercise the `stdout` and | ||
| 410 | `none` paths so `cargo test` needs no SMTP server. A **SHOULD** deviation logged | ||
| 411 | here. The invite token reuses `auth::new_session_token`'s shape (32 bytes, | ||
| 412 | base64url value emailed, SHA-256 stored). | ||
fabrica.example.toml +1 −0
| @@ -51,6 +51,7 @@ max_connections = 16 | |||
| 51 | secret_file = "/var/lib/fabrica/secret" # generated on first run, mode 0600 | 51 | secret_file = "/var/lib/fabrica/secret" # generated on first run, mode 0600 |
| 52 | session_ttl_days = 30 | 52 | session_ttl_days = 30 |
| 53 | token_ttl_days = 90 | 53 | token_ttl_days = 90 |
| 54 | invite_ttl_hours = 72 # activation/reset link lifetime | ||
| 54 | cookie_name = "fabrica_session" | 55 | cookie_name = "fabrica_session" |
| 55 | cookie_secure = true # set false only if instance.url is plain http (dev) | 56 | cookie_secure = true # set false only if instance.url is plain http (dev) |
| 56 | argon2_m_cost = 19456 # KiB | 57 | argon2_m_cost = 19456 # KiB |