summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2024-09-03 01:25:20 -0400
committerOwen Jacobson <owen@grimoire.ca>2024-09-03 02:09:25 -0400
commitb404344a7c4ab5cb6c7d7b445fab796be79b848f (patch)
treec476b125316b9d4aa7bdece7c9bb8e2f65d2961e
parent92a7518975c6bc4b2f9b9c6c12c458b24e8cfaf5 (diff)
Allow login creation and authentication.
This is a beefy change, as it adds a TON of smaller pieces needed to make this all function: * A database migration. * A ton of new crates for things like password validation, timekeeping, and HTML generation. * A first cut at a module structure for routes, templates, repositories. * A family of ID types, for identifying various kinds of domain thing. * AppError, which _doesn't_ implement Error but can be sent to clients.
-rw-r--r--.envrc2
-rw-r--r--.sqlx/query-07dc90365168e34f2640bb55c2ca8b868804d4dfcf5d4bdb55d0db9ba883f7a8.json20
-rw-r--r--.sqlx/query-67a4bc5f758852e19c50fab42d3fa9460e220c662d83c8af6e3412a317076e4d.json26
-rw-r--r--.sqlx/query-73f26168299574e17f0a21da5b6914e66b5ceeec04ffc2f5bf7d170b7dd3a1e9.json20
-rw-r--r--Cargo.lock324
-rw-r--r--Cargo.toml13
-rw-r--r--git-hooks/README14
-rwxr-xr-xgit-hooks/pre-commit10
-rw-r--r--migrations/.gitignore0
-rw-r--r--migrations/20240831024047_login.sql22
-rw-r--r--src/cli.rs16
-rw-r--r--src/error.rs37
-rw-r--r--src/id.rs49
-rw-r--r--src/index.rs37
-rw-r--r--src/lib.rs4
-rw-r--r--src/login/mod.rs4
-rw-r--r--src/login/repo/logins.rs167
-rw-r--r--src/login/repo/mod.rs2
-rw-r--r--src/login/repo/tokens.rs47
-rw-r--r--src/login/routes.rs78
20 files changed, 883 insertions, 9 deletions
diff --git a/.envrc b/.envrc
index 63d6803..0423909 100644
--- a/.envrc
+++ b/.envrc
@@ -1,4 +1,6 @@
PATH_add tools
PATH_add target/debug
+export DATABASE_URL="${DATABASE_URL:-sqlite://.hi?mode=rwc}"
+
source_env_if_exists .envrc.local
diff --git a/.sqlx/query-07dc90365168e34f2640bb55c2ca8b868804d4dfcf5d4bdb55d0db9ba883f7a8.json b/.sqlx/query-07dc90365168e34f2640bb55c2ca8b868804d4dfcf5d4bdb55d0db9ba883f7a8.json
new file mode 100644
index 0000000..91a1e49
--- /dev/null
+++ b/.sqlx/query-07dc90365168e34f2640bb55c2ca8b868804d4dfcf5d4bdb55d0db9ba883f7a8.json
@@ -0,0 +1,20 @@
+{
+ "db_name": "SQLite",
+ "query": "\n insert or fail\n into login (id, name, password_hash)\n values ($1, $2, $3)\n returning id as \"id: Id\"\n ",
+ "describe": {
+ "columns": [
+ {
+ "name": "id: Id",
+ "ordinal": 0,
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Right": 3
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "07dc90365168e34f2640bb55c2ca8b868804d4dfcf5d4bdb55d0db9ba883f7a8"
+}
diff --git a/.sqlx/query-67a4bc5f758852e19c50fab42d3fa9460e220c662d83c8af6e3412a317076e4d.json b/.sqlx/query-67a4bc5f758852e19c50fab42d3fa9460e220c662d83c8af6e3412a317076e4d.json
new file mode 100644
index 0000000..a4dffb5
--- /dev/null
+++ b/.sqlx/query-67a4bc5f758852e19c50fab42d3fa9460e220c662d83c8af6e3412a317076e4d.json
@@ -0,0 +1,26 @@
+{
+ "db_name": "SQLite",
+ "query": "\n select\n id as \"id: Id\",\n password_hash as \"password_hash: StoredHash\"\n from login\n where name = $1\n ",
+ "describe": {
+ "columns": [
+ {
+ "name": "id: Id",
+ "ordinal": 0,
+ "type_info": "Text"
+ },
+ {
+ "name": "password_hash: StoredHash",
+ "ordinal": 1,
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": [
+ false,
+ false
+ ]
+ },
+ "hash": "67a4bc5f758852e19c50fab42d3fa9460e220c662d83c8af6e3412a317076e4d"
+}
diff --git a/.sqlx/query-73f26168299574e17f0a21da5b6914e66b5ceeec04ffc2f5bf7d170b7dd3a1e9.json b/.sqlx/query-73f26168299574e17f0a21da5b6914e66b5ceeec04ffc2f5bf7d170b7dd3a1e9.json
new file mode 100644
index 0000000..eb1bae1
--- /dev/null
+++ b/.sqlx/query-73f26168299574e17f0a21da5b6914e66b5ceeec04ffc2f5bf7d170b7dd3a1e9.json
@@ -0,0 +1,20 @@
+{
+ "db_name": "SQLite",
+ "query": "\n insert\n into token (secret, login, issued_at)\n values ($1, $2, $3)\n returning secret as \"secret!\"\n ",
+ "describe": {
+ "columns": [
+ {
+ "name": "secret!",
+ "ordinal": 0,
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Right": 3
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "73f26168299574e17f0a21da5b6914e66b5ceeec04ffc2f5bf7d170b7dd3a1e9"
+}
diff --git a/Cargo.lock b/Cargo.lock
index 73d671e..a05554d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -36,6 +36,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f"
[[package]]
+name = "android-tzdata"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
name = "anstream"
version = "0.6.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -85,6 +100,18 @@ dependencies = [
]
[[package]]
+name = "argon2"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
+dependencies = [
+ "base64ct",
+ "blake2",
+ "cpufeatures",
+ "password-hash",
+]
+
+[[package]]
name = "async-trait"
version = "0.1.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -166,6 +193,29 @@ dependencies = [
]
[[package]]
+name = "axum-extra"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0be6ea09c9b96cb5076af0de2e383bd2bc0c18f827cf1967bdd353e0b910d733"
+dependencies = [
+ "axum",
+ "axum-core",
+ "bytes",
+ "cookie",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "serde",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
name = "backtrace"
version = "0.3.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -208,6 +258,15 @@ dependencies = [
]
[[package]]
+name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest",
+]
+
+[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -217,6 +276,12 @@ dependencies = [
]
[[package]]
+name = "bumpalo"
+version = "3.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
+
+[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -244,6 +309,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
+name = "chrono"
+version = "0.4.38"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
+dependencies = [
+ "android-tzdata",
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
name = "clap"
version = "4.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -305,6 +384,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
+name = "cookie"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
+dependencies = [
+ "percent-encoding",
+ "time",
+ "version_check",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
name = "cpufeatures"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -365,6 +461,15 @@ dependencies = [
]
[[package]]
+name = "deranged"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
+dependencies = [
+ "powerfmt",
+]
+
+[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -601,10 +706,19 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
name = "hi"
version = "0.1.0"
dependencies = [
+ "argon2",
"axum",
+ "axum-extra",
+ "chrono",
"clap",
+ "maud",
+ "password-hash",
+ "rand",
+ "rand_core",
+ "serde",
"sqlx",
"tokio",
+ "uuid",
]
[[package]]
@@ -715,6 +829,29 @@ dependencies = [
]
[[package]]
+name = "iana-time-zone"
+version = "0.1.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
name = "idna"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -747,6 +884,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
[[package]]
+name = "js-sys"
+version = "0.3.70"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a"
+dependencies = [
+ "wasm-bindgen",
+]
+
+[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -807,6 +953,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
+name = "maud"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df518b75016b4289cdddffa1b01f2122f4a49802c93191f3133f6dc2472ebcaa"
+dependencies = [
+ "axum-core",
+ "http",
+ "itoa",
+ "maud_macros",
+]
+
+[[package]]
+name = "maud_macros"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa453238ec218da0af6b11fc5978d3b5c3a45ed97b722391a2a11f3306274e18"
+dependencies = [
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -883,6 +1053,12 @@ dependencies = [
]
[[package]]
+name = "num-conv"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+
+[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -957,6 +1133,17 @@ dependencies = [
]
[[package]]
+name = "password-hash"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
+dependencies = [
+ "base64ct",
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1037,6 +1224,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
name = "ppv-lite86"
version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1046,6 +1239,29 @@ dependencies = [
]
[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
+[[package]]
name = "proc-macro2"
version = "1.0.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1339,6 +1555,7 @@ dependencies = [
"atoi",
"byteorder",
"bytes",
+ "chrono",
"crc",
"crossbeam-queue",
"either",
@@ -1419,6 +1636,7 @@ dependencies = [
"bitflags 2.6.0",
"byteorder",
"bytes",
+ "chrono",
"crc",
"digest",
"dotenvy",
@@ -1460,6 +1678,7 @@ dependencies = [
"base64",
"bitflags 2.6.0",
"byteorder",
+ "chrono",
"crc",
"dotenvy",
"etcetera",
@@ -1495,6 +1714,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75b419c3c1b1697833dd927bdc4c6545a620bc1bbafabd44e1efbe9afcd337e"
dependencies = [
"atoi",
+ "chrono",
"flume",
"futures-channel",
"futures-core",
@@ -1591,6 +1811,37 @@ dependencies = [
]
[[package]]
+name = "time"
+version = "0.3.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
+dependencies = [
+ "deranged",
+ "itoa",
+ "num-conv",
+ "powerfmt",
+ "serde",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
+
+[[package]]
+name = "time-macros"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
name = "tinyvec"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1760,6 +2011,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
+name = "uuid"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1784,6 +2044,61 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
+name = "wasm-bindgen"
+version = "0.2.93"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.93"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b"
+dependencies = [
+ "bumpalo",
+ "log",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.93"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.93"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.93"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484"
+
+[[package]]
name = "whoami"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1794,6 +2109,15 @@ dependencies = [
]
[[package]]
+name = "windows-core"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 0ff0a1b..9f7aa4a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,16 @@ version = "0.1.0"
edition = "2021"
[dependencies]
-axum = "0.7.5"
+argon2 = "0.5.3"
+axum = { version = "0.7.5", features = ["form"] }
+axum-extra = { version = "0.9.3", features = ["cookie"] }
+chrono = "0.4.38"
clap = { version = "4.5.16", features = ["derive", "env"] }
-sqlx = { version = "0.8.1", features = ["runtime-tokio", "sqlite"] }
+maud = { version = "0.26.0", features = ["axum"] }
+password-hash = { version = "0.5.0", features = ["std"] }
+rand = "0.8.5"
+rand_core = { version = "0.6.4", features = ["getrandom"] }
+serde = { version = "1.0.209", features = ["derive"] }
+sqlx = { version = "0.8.1", features = ["chrono", "runtime-tokio", "sqlite"] }
tokio = { version = "1.40.0", features = ["rt", "macros", "rt-multi-thread"] }
+uuid = { version = "1.10.0", features = ["v4"] }
diff --git a/git-hooks/README b/git-hooks/README
new file mode 100644
index 0000000..f761258
--- /dev/null
+++ b/git-hooks/README
@@ -0,0 +1,14 @@
+This directory contains OPTIONAL git hooks, intended to make development
+easier by catching common issues.
+
+You can either install them _en bloc_ by running
+
+ git config core.hooksPath git-hooks
+
+in this repository, or install them individually by symlinking them from the
+normal git hooks path.
+
+As always, don't run code you don't trust. Installing git hooks will cause
+git to run them sooner or later.
+
+Enjoy!
diff --git a/git-hooks/pre-commit b/git-hooks/pre-commit
new file mode 100755
index 0000000..98f1c37
--- /dev/null
+++ b/git-hooks/pre-commit
@@ -0,0 +1,10 @@
+#!/bin/bash -e
+
+# Don't put anything here that routinely takes longer than a second or so to
+# run. It gets old fast. That's why this uses `cargo check` and not `cargo
+# test`, for example.
+
+# Make sure there are no screamers in the code.
+cargo check
+# Make sure the prepared statement data in .sqlx is up to date.
+cargo sqlx prepare --check
diff --git a/migrations/.gitignore b/migrations/.gitignore
deleted file mode 100644
index e69de29..0000000
--- a/migrations/.gitignore
+++ /dev/null
diff --git a/migrations/20240831024047_login.sql b/migrations/20240831024047_login.sql
new file mode 100644
index 0000000..758e9c7
--- /dev/null
+++ b/migrations/20240831024047_login.sql
@@ -0,0 +1,22 @@
+create table login (
+ id text
+ not null
+ primary key,
+ name text
+ not null
+ unique,
+ password_hash text
+ not null
+);
+
+create table token (
+ secret text
+ not null
+ primary key,
+ login text
+ not null,
+ issued_at text
+ not null,
+ foreign key (login)
+ references login (id)
+);
diff --git a/src/cli.rs b/src/cli.rs
index 0880020..191e331 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -1,12 +1,14 @@
use std::io;
use std::str::FromStr;
-use axum::{routing::get, Router};
+use axum::Router;
use clap::Parser;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use tokio::net;
-pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
+use crate::{error::BoxedError, index, login};
+
+pub type Result<T> = std::result::Result<T, BoxedError>;
#[derive(Parser)]
pub struct Args {
@@ -26,7 +28,7 @@ impl Args {
sqlx::migrate!().run(&pool).await?;
- let app = Router::new().route("/", get(hello)).with_state(pool);
+ let app = routers().with_state(pool);
let listener = self.listener().await?;
let started_msg = started_msg(&listener)?;
@@ -58,11 +60,11 @@ impl Args {
}
}
+fn routers() -> Router<SqlitePool> {
+ index::router().merge(login::router())
+}
+
fn started_msg(listener: &net::TcpListener) -> io::Result<String> {
let local_addr = listener.local_addr()?;
Ok(format!("listening on http://{local_addr}/"))
}
-
-async fn hello() -> &'static str {
- "Hello, world"
-}
diff --git a/src/error.rs b/src/error.rs
new file mode 100644
index 0000000..2d512e6
--- /dev/null
+++ b/src/error.rs
@@ -0,0 +1,37 @@
+use std::error;
+
+use axum::{
+ http::StatusCode,
+ response::{IntoResponse, Response},
+};
+
+// I'm making an effort to avoid `anyhow` here, as that crate is _enormously_
+// complex (though very usable). We don't need to be overly careful about
+// allocations on errors in this app, so this is fine for most "general
+// failure" cases.
+//
+// If that changes, my hope is to use `thiserror` or something with a similar
+// strategy, before resorting to `anyhow`.
+pub type BoxedError = Box<dyn error::Error + Send + Sync>;
+
+// Returns a 500 Internal Server Error to the client. Meant to be used via the
+// `?` operator; _does not_ return the originating error to the client.
+#[derive(Debug)]
+pub struct InternalError;
+
+impl<E> From<E> for InternalError
+where
+ E: Into<BoxedError>,
+{
+ fn from(_: E) -> InternalError {
+ // At some point it may be useful for this to record the originating
+ // error so that it can be logged… -oj
+ InternalError
+ }
+}
+
+impl IntoResponse for InternalError {
+ fn into_response(self) -> Response {
+ (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response()
+ }
+}
diff --git a/src/id.rs b/src/id.rs
new file mode 100644
index 0000000..f630107
--- /dev/null
+++ b/src/id.rs
@@ -0,0 +1,49 @@
+use rand::{seq::SliceRandom, thread_rng};
+
+// Make IDs that:
+//
+// * Do not require escaping in URLs
+// * Do not require escaping in hostnames
+// * Are unique up to case conversion
+// * Are relatively unlikely to contain cursewords
+// * Are relatively unlikely to contain visually similar characters in most typefaces
+// * Are not sequential
+//
+// This leaves 23 ASCII characters, or about 4.52 bits of entropy per character
+// if generated with uniform probability.
+pub const ALPHABET: [char; 23] = [
+ '1', '2', '3', '4', '6', '7', '8', '9', 'b', 'c', 'd', 'f', 'h', 'j', 'k', 'n', 'p', 'r', 's',
+ 't', 'w', 'x', 'y',
+];
+
+// Pick enough characters per ID to make accidental collisions "acceptably" unlikely
+// without also making them _too_ unwieldy. This gives a fraction under 68 bits per ID.
+pub const ID_SIZE: usize = 15;
+
+// Intended to be wrapped in a newtype that provides both type-based separation
+// from other identifier types, and a unique prefix to allow the intended type
+// of an ID to be determined by eyeball when debugging.
+//
+// By convention, the prefix should be UPPERCASE - note that the alphabet for this
+// is entirely lowercase.
+#[derive(Debug, Hash, PartialEq, Eq, sqlx::Type)]
+#[sqlx(transparent)]
+pub struct Id(String);
+
+impl Id {
+ pub fn generate<T>(prefix: &str) -> T
+ where
+ T: From<Self>,
+ {
+ let mut rng = thread_rng();
+ let id = prefix
+ .chars()
+ .chain(
+ (0..ID_SIZE)
+ .flat_map(|_| ALPHABET.choose(&mut rng)) /* usize -> &char */
+ .cloned(), /* &char -> char */
+ )
+ .collect::<String>();
+ T::from(Self(id))
+ }
+}
diff --git a/src/index.rs b/src/index.rs
new file mode 100644
index 0000000..6411ff4
--- /dev/null
+++ b/src/index.rs
@@ -0,0 +1,37 @@
+use axum::{response::IntoResponse, routing::get, Router};
+
+pub fn router<S>() -> Router<S>
+where
+ S: Send + Sync + Clone + 'static,
+{
+ Router::new().route("/", get(index))
+}
+
+async fn index() -> impl IntoResponse {
+ templates::index()
+}
+
+mod templates {
+ use maud::{html, Markup, DOCTYPE};
+ pub fn index() -> Markup {
+ html! {
+ (DOCTYPE)
+ head {
+ title { "hi" }
+ }
+ body {
+ form action="/login" method="post" {
+ label {
+ "name"
+ input name="name" type="text" {}
+ }
+ label {
+ "password"
+ input name="password" type="password" {}
+ }
+ button { "hi" }
+ }
+ }
+ }
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 4f77372..c91ca43 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1 +1,5 @@
pub mod cli;
+mod error;
+mod id;
+mod index;
+mod login;
diff --git a/src/login/mod.rs b/src/login/mod.rs
new file mode 100644
index 0000000..8769407
--- /dev/null
+++ b/src/login/mod.rs
@@ -0,0 +1,4 @@
+pub use self::routes::router;
+
+mod repo;
+mod routes;
diff --git a/src/login/repo/logins.rs b/src/login/repo/logins.rs
new file mode 100644
index 0000000..c6db86e
--- /dev/null
+++ b/src/login/repo/logins.rs
@@ -0,0 +1,167 @@
+use argon2::Argon2;
+use password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
+use rand_core::OsRng;
+use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction};
+
+use crate::error::BoxedError;
+use crate::id::Id as BaseId;
+
+pub trait Provider {
+ fn logins(&mut self) -> Logins;
+}
+
+impl<'c> Provider for Transaction<'c, Sqlite> {
+ fn logins(&mut self) -> Logins {
+ Logins(self)
+ }
+}
+
+pub struct Logins<'t>(&'t mut SqliteConnection);
+
+#[derive(Debug)]
+pub struct Login {
+ pub id: Id,
+ // Field unused (as of this writing), omitted to avoid warnings.
+ // Feel free to add it:
+ //
+ // pub name: String,
+
+ // However, the omission of the hashed password is deliberate, to minimize
+ // the chance that it ends up tangled up in debug output or in some other
+ // chunk of logic elsewhere.
+}
+
+impl<'c> Logins<'c> {
+ /// Create a new login, if the name is not already taken. Returns a [Login]
+ /// if a new login has actually been created, or `None` if an existing login
+ /// was found.
+ pub async fn create(
+ &mut self,
+ name: &str,
+ password: &str,
+ ) -> Result<Option<Login>, BoxedError> {
+ let id = Id::generate();
+ let password_hash = StoredHash::new(password)?;
+
+ let insert_res = sqlx::query_as!(
+ Login,
+ r#"
+ insert or fail
+ into login (id, name, password_hash)
+ values ($1, $2, $3)
+ returning id as "id: Id"
+ "#,
+ id,
+ name,
+ password_hash,
+ )
+ .fetch_one(&mut *self.0)
+ .await;
+
+ let result = match insert_res {
+ Ok(id) => Ok(Some(id)),
+ Err(err) => {
+ if let Some(true) = err
+ .as_database_error()
+ .map(|db_err| db_err.is_unique_violation())
+ {
+ // Login with the same username (or, very rarely, same ID) already
+ // exists.
+ Ok(None)
+ } else {
+ Err(err)
+ }
+ }
+ }?;
+ Ok(result)
+ }
+
+ /// Authenticates `name` and `password` against an existing [Login]. Returns
+ /// that [Login] if one was found and the password was correct, or `None` if
+ /// either condition does not hold.
+ pub async fn authenticate(
+ &mut self,
+ name: &str,
+ password: &str,
+ ) -> Result<Option<Login>, BoxedError> {
+ let found = self.for_name(name).await?;
+
+ let login = if let Some((login, stored_hash)) = found {
+ if stored_hash.verify(password)? {
+ // User found and password validation succeeded.
+ Some(login)
+ } else {
+ // Password validation failed.
+ None
+ }
+ } else {
+ // User not found.
+ None
+ };
+
+ Ok(login)
+ }
+
+ async fn for_name(&mut self, name: &str) -> Result<Option<(Login, StoredHash)>, BoxedError> {
+ let found = sqlx::query!(
+ r#"
+ select
+ id as "id: Id",
+ password_hash as "password_hash: StoredHash"
+ from login
+ where name = $1
+ "#,
+ name,
+ )
+ .map(|rec| (Login { id: rec.id }, rec.password_hash))
+ .fetch_optional(&mut *self.0)
+ .await?;
+
+ Ok(found)
+ }
+}
+
+/// Stable identifier for a [Login]. Prefixed with `L`.
+#[derive(Debug, sqlx::Type)]
+#[sqlx(transparent)]
+pub struct Id(BaseId);
+
+impl From<BaseId> for Id {
+ fn from(id: BaseId) -> Self {
+ Self(id)
+ }
+}
+
+impl Id {
+ pub fn generate() -> Self {
+ BaseId::generate("L")
+ }
+}
+
+#[derive(Debug, sqlx::Type)]
+#[sqlx(transparent)]
+struct StoredHash(String);
+
+impl StoredHash {
+ fn new(password: &str) -> Result<Self, password_hash::Error> {
+ let salt = SaltString::generate(&mut OsRng);
+ let argon2 = Argon2::default();
+ let hash = argon2
+ .hash_password(password.as_bytes(), &salt)?
+ .to_string();
+ Ok(Self(hash))
+ }
+
+ fn verify(&self, password: &str) -> Result<bool, password_hash::Error> {
+ let hash = PasswordHash::new(&self.0)?;
+
+ match Argon2::default().verify_password(password.as_bytes(), &hash) {
+ // Successful authentication, not an error
+ Ok(()) => Ok(true),
+ // Unsuccessful authentication, also not an error
+ Err(password_hash::errors::Error::Password) => Ok(false),
+ // Password validation failed for some other reason, treat as an error
+ Err(err) => Err(err),
+ }
+ }
+}
diff --git a/src/login/repo/mod.rs b/src/login/repo/mod.rs
new file mode 100644
index 0000000..07da569
--- /dev/null
+++ b/src/login/repo/mod.rs
@@ -0,0 +1,2 @@
+pub mod logins;
+pub mod tokens;
diff --git a/src/login/repo/tokens.rs b/src/login/repo/tokens.rs
new file mode 100644
index 0000000..080e35a
--- /dev/null
+++ b/src/login/repo/tokens.rs
@@ -0,0 +1,47 @@
+use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction};
+use uuid::Uuid;
+
+use super::logins::Id as LoginId;
+use crate::error::BoxedError;
+
+type DateTime = chrono::DateTime<chrono::Utc>;
+
+pub trait Provider {
+ fn tokens(&mut self) -> Tokens;
+}
+
+impl<'c> Provider for Transaction<'c, Sqlite> {
+ fn tokens(&mut self) -> Tokens {
+ Tokens(self)
+ }
+}
+
+pub struct Tokens<'t>(&'t mut SqliteConnection);
+
+impl<'c> Tokens<'c> {
+ /// Issue a new token for an existing login. The issued_at timestamp will
+ /// be used to control expiry.
+ pub async fn issue(
+ &mut self,
+ login: &LoginId,
+ issued_at: DateTime,
+ ) -> Result<String, BoxedError> {
+ let secret = Uuid::new_v4().to_string();
+
+ let secret = sqlx::query_scalar!(
+ r#"
+ insert
+ into token (secret, login, issued_at)
+ values ($1, $2, $3)
+ returning secret as "secret!"
+ "#,
+ secret,
+ login,
+ issued_at,
+ )
+ .fetch_one(&mut *self.0)
+ .await?;
+
+ Ok(secret)
+ }
+}
diff --git a/src/login/routes.rs b/src/login/routes.rs
new file mode 100644
index 0000000..c9def2a
--- /dev/null
+++ b/src/login/routes.rs
@@ -0,0 +1,78 @@
+use axum::{
+ extract::{Form, State},
+ http::StatusCode,
+ response::IntoResponse,
+ routing::post,
+ Router,
+};
+use axum_extra::extract::cookie::{Cookie, CookieJar};
+use chrono::Utc;
+use sqlx::sqlite::SqlitePool;
+
+use crate::error::InternalError;
+
+use super::repo::{logins::Provider as _, tokens::Provider as _};
+
+pub fn router() -> Router<SqlitePool> {
+ Router::new().route("/login", post(on_login))
+}
+
+#[derive(serde::Deserialize)]
+struct Login {
+ name: String,
+ password: String,
+}
+
+async fn on_login(
+ State(db): State<SqlitePool>,
+ cookies: CookieJar,
+ Form(form): Form<Login>,
+) -> Result<impl IntoResponse, InternalError> {
+ let now = Utc::now();
+ let mut tx = db.begin().await?;
+
+ // Spelling the following in the more conventional form,
+ // if let Some(…) = create().await? {}
+ // else if let Some(…) = validate().await? {}
+ // else {}
+ // pushes the specifics of whether the returned error types are Send or not
+ // (they aren't) into the type of this function's generated Futures, which
+ // in turn makes this function unusable as an Axum handler.
+ let login = tx.logins().create(&form.name, &form.password).await?;
+ let login = if login.is_some() {
+ login
+ } else {
+ tx.logins().authenticate(&form.name, &form.password).await?
+ };
+
+ // If `login` is Some, then we have an identity and can issue an identity
+ // token. If `login` is None, then neither creating a new login nor authenticating
+ // an existing one succeeded, and we must reject the attempt.
+ //
+ // These properties will be transferred to `token`, as well.
+ let token = if let Some(login) = login {
+ Some(tx.tokens().issue(&login.id, now).await?)
+ } else {
+ None
+ };
+
+ tx.commit().await?;
+
+ let resp = if let Some(token) = token {
+ let cookie = Cookie::build(("identity", token))
+ .http_only(true)
+ .permanent()
+ .build();
+ let cookies = cookies.add(cookie);
+
+ (StatusCode::OK, cookies, "logged in")
+ } else {
+ (
+ StatusCode::UNAUTHORIZED,
+ cookies,
+ "invalid name or password",
+ )
+ };
+
+ Ok(resp)
+}