diff options
| author | Owen Jacobson <owen@grimoire.ca> | 2024-09-03 01:25:20 -0400 |
|---|---|---|
| committer | Owen Jacobson <owen@grimoire.ca> | 2024-09-03 02:09:25 -0400 |
| commit | b404344a7c4ab5cb6c7d7b445fab796be79b848f (patch) | |
| tree | c476b125316b9d4aa7bdece7c9bb8e2f65d2961e | |
| parent | 92a7518975c6bc4b2f9b9c6c12c458b24e8cfaf5 (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-- | .envrc | 2 | ||||
| -rw-r--r-- | .sqlx/query-07dc90365168e34f2640bb55c2ca8b868804d4dfcf5d4bdb55d0db9ba883f7a8.json | 20 | ||||
| -rw-r--r-- | .sqlx/query-67a4bc5f758852e19c50fab42d3fa9460e220c662d83c8af6e3412a317076e4d.json | 26 | ||||
| -rw-r--r-- | .sqlx/query-73f26168299574e17f0a21da5b6914e66b5ceeec04ffc2f5bf7d170b7dd3a1e9.json | 20 | ||||
| -rw-r--r-- | Cargo.lock | 324 | ||||
| -rw-r--r-- | Cargo.toml | 13 | ||||
| -rw-r--r-- | git-hooks/README | 14 | ||||
| -rwxr-xr-x | git-hooks/pre-commit | 10 | ||||
| -rw-r--r-- | migrations/.gitignore | 0 | ||||
| -rw-r--r-- | migrations/20240831024047_login.sql | 22 | ||||
| -rw-r--r-- | src/cli.rs | 16 | ||||
| -rw-r--r-- | src/error.rs | 37 | ||||
| -rw-r--r-- | src/id.rs | 49 | ||||
| -rw-r--r-- | src/index.rs | 37 | ||||
| -rw-r--r-- | src/lib.rs | 4 | ||||
| -rw-r--r-- | src/login/mod.rs | 4 | ||||
| -rw-r--r-- | src/login/repo/logins.rs | 167 | ||||
| -rw-r--r-- | src/login/repo/mod.rs | 2 | ||||
| -rw-r--r-- | src/login/repo/tokens.rs | 47 | ||||
| -rw-r--r-- | src/login/routes.rs | 78 |
20 files changed, 883 insertions, 9 deletions
@@ -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" +} @@ -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" @@ -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) +); @@ -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" } + } + } + } + } +} @@ -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) +} |
