From 2adf8249bb8843b45a9c1697e37e9b79e3be61ee Mon Sep 17 00:00:00 2001 From: "NG (Graham)" Date: Sun, 5 Jul 2026 15:45:36 -0400 Subject: [PATCH] Implement federated login; #123 --- Cargo.lock | 338 +++++++++-- Cargo.toml | 8 +- rc_auth/Cargo.toml | 2 + rc_auth/src/main.rs | 9 +- rc_auth/src/oauth/auth.rs | 38 ++ rc_auth/src/oauth/jwks.rs | 9 + rc_auth/src/oauth/mod.rs | 4 + rc_auth/src/oauth/openid_config.rs | 65 ++ rc_auth/src/oauth/token.rs | 40 ++ rc_auth/src/robocraft/displayname.rs | 32 + rc_auth/src/robocraft/intercom/services.rs | 3 +- rc_auth/src/robocraft/mod.rs | 1 + rc_core/Cargo.toml | 6 +- rc_core/src/auth/token.rs | 8 + rc_core/src/persist/config/cubes_json.rs | 1 + rc_core/src/persist/config/traits.rs | 1 + rc_core/src/persist/federation.rs | 5 + rc_core/src/persist/settings.rs | 7 + rc_core/src/persist/user/account_json.rs | 157 ++--- rc_core/src/persist/user/federation.rs | 561 ++++++++++++++++++ rc_core/src/persist/user/initial_data.rs | 34 ++ rc_core/src/persist/user/intercom.rs | 12 +- rc_core/src/persist/user/mod.rs | 4 +- rc_core/src/persist/user/traits.rs | 17 +- ...20260705_000001_create_federation_table.rs | 44 ++ .../m20260705_000002_add_user_federation.rs | 69 +++ rc_database/src/migration/mod.rs | 4 + rc_database/src/schema/federation.rs | 29 + rc_database/src/schema/mod.rs | 1 + rc_database/src/schema/user.rs | 13 + rc_database/src/wrapper.rs | 36 ++ rc_society/Cargo.toml | 1 + rc_society/src/api/mod.rs | 2 + rc_society/src/api/urls.rs | 25 + rc_society/src/main.rs | 1 + 35 files changed, 1466 insertions(+), 121 deletions(-) create mode 100644 rc_auth/src/oauth/auth.rs create mode 100644 rc_auth/src/oauth/jwks.rs create mode 100644 rc_auth/src/oauth/mod.rs create mode 100644 rc_auth/src/oauth/openid_config.rs create mode 100644 rc_auth/src/oauth/token.rs create mode 100644 rc_auth/src/robocraft/displayname.rs create mode 100644 rc_database/src/migration/m20260705_000001_create_federation_table.rs create mode 100644 rc_database/src/migration/m20260705_000002_add_user_federation.rs create mode 100644 rc_database/src/schema/federation.rs create mode 100644 rc_society/src/api/urls.rs diff --git a/Cargo.lock b/Cargo.lock index a2b540c..4f72309 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,7 +137,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e11eb847f49a700678ea2fa73daeb3208061afa2b9d1a8527c03390f4c4a1c6b" dependencies = [ - "darling", + "darling 0.20.11", "parse-size", "proc-macro2", "quote", @@ -619,6 +619,12 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -739,6 +745,15 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -968,7 +983,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1231,8 +1246,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -1249,13 +1274,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn 2.0.117", ] @@ -1308,7 +1357,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn 2.0.117", @@ -1408,6 +1457,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ecdsa" version = "0.16.9" @@ -1890,7 +1945,7 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "walkdir", ] @@ -2287,6 +2342,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -2350,6 +2416,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2401,7 +2476,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.18", "walkdir", "windows-link", ] @@ -2947,6 +3022,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http 1.4.0", + "rand 0.8.6", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "thiserror 1.0.69", + "url", +] + [[package]] name = "obj" version = "0.10.2" @@ -2970,7 +3065,7 @@ dependencies = [ [[package]] name = "oj_convert" -version = "0.3.0" +version = "0.4.0" dependencies = [ "bytes", "chrono", @@ -3009,9 +3104,11 @@ dependencies = [ "log", "oj_rc_core", "oj_serdes", + "openidconnect", "serde", "serde_json", "tokio", + "urlencoding", ] [[package]] @@ -3060,7 +3157,7 @@ dependencies = [ "chrono", "futures", "hex", - "indexmap", + "indexmap 2.14.0", "jsonwebtoken", "libfj", "log", @@ -3068,15 +3165,18 @@ dependencies = [ "oj_rc_database", "oj_rc_factory", "oj_serdes", + "openidconnect", "polariton", "polariton_server", "rand 0.10.1", "reqwest 0.13.3", "reqwest-websocket", + "ring", "serde", "serde_json", "sha2 0.11.0", "tokio", + "urlencoding", ] [[package]] @@ -3084,7 +3184,7 @@ name = "oj_rc_database" version = "1.3.0" dependencies = [ "async-trait", - "itertools", + "itertools 0.14.0", "libfj", "oj_rc_factory", "sea-orm", @@ -3328,6 +3428,7 @@ dependencies = [ "oj_rc_core", "oj_rc_factory", "oj_rc_plugins", + "oj_serdes", "serde", "serde_json", "tokio", @@ -3335,9 +3436,7 @@ dependencies = [ [[package]] name = "oj_serdes" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd23d2f1a6d461c2e2ef1a1a12c27fd8af4e68cdee91c32ae93aeecbb84bf929" +version = "0.4.0" dependencies = [ "quick-xml", "serde", @@ -3361,12 +3460,52 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openidconnect" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac 0.12.1", + "http 1.4.0", + "itertools 0.10.5", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.6", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2 0.10.9", + "subtle", + "thiserror 1.0.69", + "url", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits 0.2.19", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -3826,7 +3965,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2 0.6.3", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -3848,7 +3987,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -4026,6 +4165,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "regex" version = "1.12.3" @@ -4135,6 +4294,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -4159,7 +4319,7 @@ dependencies = [ "reqwest 0.13.3", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -4403,6 +4563,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -4446,7 +4630,7 @@ dependencies = [ "serde_json", "sqlx", "strum 0.26.3", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "url", @@ -4508,7 +4692,7 @@ dependencies = [ "bigdecimal", "chrono", "inherent", - "ordered-float", + "ordered-float 4.6.0", "rust_decimal", "sea-query-derive", "serde_json", @@ -4538,12 +4722,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab" dependencies = [ - "darling", + "darling 0.20.11", "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.117", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -4639,6 +4823,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float 2.10.1", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4672,6 +4866,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_plain" version = "1.0.2" @@ -4693,6 +4898,38 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sha1" version = "0.10.6" @@ -4808,7 +5045,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits 0.2.19", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -4899,7 +5136,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap", + "indexmap 2.14.0", "log", "memchr", "once_cell", @@ -4910,7 +5147,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -4998,7 +5235,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "uuid", @@ -5041,7 +5278,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "uuid", @@ -5068,7 +5305,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "url", @@ -5198,13 +5435,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -5369,7 +5626,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime 0.6.11", "winnow 0.5.40", ] @@ -5380,7 +5637,7 @@ version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.2", @@ -5506,7 +5763,7 @@ dependencies = [ "log", "rand 0.9.4", "sha1 0.10.6", - "thiserror", + "thiserror 2.0.18", "utf-8", ] @@ -5617,8 +5874,15 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -5788,7 +6052,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -5801,7 +6065,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -6226,7 +6490,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -6257,7 +6521,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -6276,7 +6540,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -6424,7 +6688,7 @@ dependencies = [ "flate2", "getrandom 0.4.2", "hmac 0.13.0", - "indexmap", + "indexmap 2.14.0", "lzma-rust2", "memchr", "pbkdf2", diff --git a/Cargo.toml b/Cargo.toml index 0db5ae2..af99371 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,8 @@ resolver = "3" [workspace.dependencies] actix-web = { version = "4", default-features = false, features = [ "macros", "compress-brotli", "compress-gzip", "compress-zstd", "ws"] } actix-files = "0.6" +openidconnect = { version = "4.0", default-features = false, features = [ "reqwest" ] } +urlencoding = "2.1" #libfj = { version = "0.10", path = "../libfj" } libfj = { version = "0.10" } log = "0.4" @@ -47,7 +49,7 @@ rand = { version = "0.10", features = [ "thread_rng" ] } num-quaternion = "1.0" hex = "0.4" base64 = "0.22" -#oj_serdes = { version = "0.3.0", path = "../oj_core/serdes" } -oj_serdes = "0.3.0" -oj_convert = { version = "0.3.0", path = "../oj_core/convert" } +oj_serdes = { version = "0.4.0", path = "../oj_core/serdes" } +#oj_serdes = "0.3.0" +oj_convert = { version = "0.4.0", path = "../oj_core/convert" } #oj_convert = "0.3.0" diff --git a/rc_auth/Cargo.toml b/rc_auth/Cargo.toml index c39d5eb..c6ccfe3 100644 --- a/rc_auth/Cargo.toml +++ b/rc_auth/Cargo.toml @@ -17,6 +17,8 @@ tokio = { version = "1.43", features = [ "rt-multi-thread" ] } futures.workspace = true clap.workspace = true oj_rc_core = { version = "*", path = "../rc_core" } +openidconnect.workspace = true +urlencoding.workspace = true libfj.workspace = true git-version.workspace = true serde.workspace = true diff --git a/rc_auth/src/main.rs b/rc_auth/src/main.rs index 7b623a4..518769f 100644 --- a/rc_auth/src/main.rs +++ b/rc_auth/src/main.rs @@ -1,6 +1,7 @@ #![forbid(unsafe_code)] mod cli; mod robocraft; +mod oauth; use actix_web::{App, HttpServer, Responder}; @@ -56,7 +57,7 @@ async fn main() -> std::io::Result<()> { App::new() .wrap_fn(|req, srv| { use actix_web::dev::Service; - log::trace!("Request {} {}", req.method(), req.path()); + log::debug!("Request {} {}", req.method(), req.path()); srv.call(req) }) .app_data(cli_args2.clone()) @@ -73,12 +74,18 @@ async fn main() -> std::io::Result<()> { .service(robocraft::email::email_password_auth) .service(robocraft::steam::steam_auth) .service(robocraft::username::user_password_auth) + .service(robocraft::displayname::displaye_password_auth) .service(robocraft::intercom::services_ws) .service(robocraft::intercom::service_msg) .service(robocraft::intercom::lobby_state_ws) .service(robocraft::intercom::lobby_state_msg) .service(robocraft::intercom::status_get) .service(robocraft::intercom::status_set) + .service(oauth::openid_config::get_openid_configuration) + .service(oauth::jwks::get_oauth_jwks) + .service(oauth::auth::post_oauth_auth) + .service(oauth::auth::get_oauth_auth) + .service(oauth::token::post_oauth_token) }) .bind((cli_args.ip, cli_args.port))? .run() diff --git a/rc_auth/src/oauth/auth.rs b/rc_auth/src/oauth/auth.rs new file mode 100644 index 0000000..6d1cad2 --- /dev/null +++ b/rc_auth/src/oauth/auth.rs @@ -0,0 +1,38 @@ +use actix_web::{get, post, web::{Data, Form, Query, Redirect}}; +use serde::{Serialize, Deserialize}; + +use oj_rc_core::persist::user::FederatedAuthenticator; + +#[derive(Serialize, Deserialize, Clone)] +struct AuthQuery { + pub response_type: Option, + pub client_id: String, + pub redirect_uri: Option, + pub scope: String, + pub state: String, + pub code_challenge: String, + pub code_challenge_method: String, +} + +#[post("/authenticate/oauth2/auth")] +pub async fn post_oauth_auth(body: Form, query: Query, config: Data) -> impl actix_web::Responder { + let access_token = match config.account_provider.remote_auth(&body, &query.code_challenge).await { + Ok(x) => x, + Err(e) => { + log::error!("Failed to OAuth authenticate {} from {}: {}", body.display_name, body.domain_source, e.message); + return Redirect::to("/") + .temporary() + } + }; + let redirect_root = query.redirect_uri.as_ref().map(|x| x.to_owned()).unwrap_or_else(|| "/authenticate/oauth2/auth".to_owned()); + let redirect_url = format!("{}?code={}&state={}", redirect_root, access_token, query.state); + #[cfg(debug_assertions)] + log::debug!("Redirecting to {}", redirect_url); + Redirect::to(redirect_url) + .temporary() +} + +#[get("/authenticate/oauth2/auth")] +pub async fn get_oauth_auth() -> &'static str { + "This is unimplemented and should not be used for standard OAuth flows" +} diff --git a/rc_auth/src/oauth/jwks.rs b/rc_auth/src/oauth/jwks.rs new file mode 100644 index 0000000..ccaff55 --- /dev/null +++ b/rc_auth/src/oauth/jwks.rs @@ -0,0 +1,9 @@ +use actix_web::{get, web::Json}; +use openidconnect::core::{CoreJsonWebKeySet, /*CoreJsonWebKey*/}; + +#[get("/authenticate/oauth2/jwks")] +pub async fn get_oauth_jwks() -> Json { + Json(CoreJsonWebKeySet::new(vec![ + // TODO ??? + ])) +} diff --git a/rc_auth/src/oauth/mod.rs b/rc_auth/src/oauth/mod.rs new file mode 100644 index 0000000..18b83a1 --- /dev/null +++ b/rc_auth/src/oauth/mod.rs @@ -0,0 +1,4 @@ +pub mod openid_config; +pub mod jwks; +pub mod auth; +pub mod token; diff --git a/rc_auth/src/oauth/openid_config.rs b/rc_auth/src/oauth/openid_config.rs new file mode 100644 index 0000000..4d63023 --- /dev/null +++ b/rc_auth/src/oauth/openid_config.rs @@ -0,0 +1,65 @@ +use actix_web::{get, web::{Data, Json}}; +use oj_rc_core::persist::user::federation::DiscoveryMetadata; + +const OAUTH_AUTH_URL: &'static str = "authenticate/oauth2/auth"; +const OAUTH_JWKS_URL: &'static str = "authenticate/oauth2/jwks"; +const OAUTH_TOKEN_URL: &'static str = "authenticate/oauth2/token"; + +#[get("/.well-known/openid-configuration")] +pub async fn get_openid_configuration(server_config: Data) -> Json { + let fallback_url = "http://127.0.0.1/fallback"; + let auth_url = format!("{}/{}", server_config.auth_url, OAUTH_AUTH_URL); + let jwks_url = format!("{}/{}", server_config.auth_url, OAUTH_JWKS_URL); // TODO + let token_url = format!("{}/{}", server_config.auth_url, OAUTH_TOKEN_URL); + let meta = DiscoveryMetadata::new( + openidconnect::IssuerUrl::new(server_config.auth_url.clone()) + .unwrap_or_else(|e| { + log::error!("Failed to parse issuer url {}: {}", server_config.auth_url, e); + openidconnect::IssuerUrl::new(fallback_url.to_owned()).unwrap() + }), + openidconnect::AuthUrl::new(auth_url.clone()) + .unwrap_or_else(|e| { + log::error!("Failed to parse auth url {}: {}", auth_url, e); + openidconnect::AuthUrl::new(fallback_url.to_owned()).unwrap() + }), + openidconnect::JsonWebKeySetUrl::new(jwks_url.clone()) + .unwrap_or_else(|e| { + log::error!("Failed to parse jwks url {}: {}", jwks_url, e); + openidconnect::JsonWebKeySetUrl::new(fallback_url.to_owned()).unwrap() + }), + vec![ + openidconnect::ResponseTypes::new(vec![openidconnect::core::CoreResponseType::Code]), + openidconnect::ResponseTypes::new(vec![openidconnect::core::CoreResponseType::IdToken]), + ], + vec![ + openidconnect::core::CoreSubjectIdentifierType::Public, + ], + vec![ + //openidconnect::core::CoreJwsSigningAlgorithm::RsaSsaPkcs1V15Sha256, + openidconnect::core::CoreJwsSigningAlgorithm::HmacSha256, + ], + openidconnect::EmptyAdditionalProviderMetadata::default(), + ) + .set_token_endpoint(Some(openidconnect::TokenUrl::new(token_url) + .unwrap_or_else(|e| { + log::error!("Failed to parse token url {}: {}", server_config.auth_url, e); + openidconnect::TokenUrl::new(fallback_url.to_owned()).unwrap() + }) + )) + .set_scopes_supported(Some(vec![ + openidconnect::Scope::new("openid".to_owned()), + openidconnect::Scope::new("read".to_owned()), + openidconnect::Scope::new("write".to_owned()), + openidconnect::Scope::new("federate".to_owned()), + ])) + .set_claims_supported(Some(vec![ + openidconnect::core::CoreClaimName::new("aud".to_owned()), + openidconnect::core::CoreClaimName::new("exp".to_owned()), + openidconnect::core::CoreClaimName::new("iat".to_owned()), + openidconnect::core::CoreClaimName::new("iss".to_owned()), + openidconnect::core::CoreClaimName::new("sub".to_owned()), + openidconnect::core::CoreClaimName::new("name".to_owned()), + openidconnect::core::CoreClaimName::new("preferred_username".to_owned()), + ])); + Json(meta) +} diff --git a/rc_auth/src/oauth/token.rs b/rc_auth/src/oauth/token.rs new file mode 100644 index 0000000..224b186 --- /dev/null +++ b/rc_auth/src/oauth/token.rs @@ -0,0 +1,40 @@ +use std::str::FromStr; + +use actix_web::{post, web::{Data, Form, Json}}; +use serde::{Serialize, Deserialize}; + +use oj_rc_core::persist::user::{FederatedAuthenticator, federation::TokenResponsePayload}; + +#[derive(Serialize, Deserialize, Clone)] +struct TokenQuery { + pub code: String, + pub client_id: String, + pub code_verifier: String, +} + +#[post("/authenticate/oauth2/token")] +pub async fn post_oauth_token(body: Form, config: Data) -> Json { + // TODO make errors compliant with OAuth2 spec + match config.account_provider.remote_token(&body.code, &body.code_verifier).await { + Ok(login_info) => { + let mut token_resp = TokenResponsePayload::new( + openidconnect::AccessToken::new(login_info.response.token.clone()), + openidconnect::core::CoreTokenType::Bearer, + openidconnect::core::CoreIdTokenFields::new( + Some(openidconnect::IdToken::from_str(&login_info.response.token).expect("Bad token")), + openidconnect::EmptyExtraTokenFields {}, + ), + ); + token_resp.set_refresh_token(Some(openidconnect::RefreshToken::new(login_info.response.refresh_token))); + Json(token_resp) + }, + Err(e) => { + log::error!("Failed OAuth2 token auth: {}", e.message); + Json(TokenResponsePayload::new( + openidconnect::AccessToken::new(String::default()), + openidconnect::core::CoreTokenType::Bearer, + openidconnect::core::CoreIdTokenFields::new(None, openidconnect::EmptyExtraTokenFields {}), + )) + } + } +} diff --git a/rc_auth/src/robocraft/displayname.rs b/rc_auth/src/robocraft/displayname.rs new file mode 100644 index 0000000..e7746a8 --- /dev/null +++ b/rc_auth/src/robocraft/displayname.rs @@ -0,0 +1,32 @@ +use oj_rc_core::persist::user::FederatedAuthenticator; +use actix_web::{post, web::{Data, Json}}; + +#[post("/authenticate/displayname/game")] +pub async fn displaye_password_auth(body: Json, config: Data) -> Result, super::ErrorTy> { + if body.display_name.is_none() { + return Err(super::ErrorTy::from_err(oj_rc_core::persist::user::AuthError { + message: "Missing display_name".to_owned(), + code: oj_rc_core::data::error_codes::AuthErrorCode::BadCredentials, + })); + } + let display_name = body.display_name.clone().unwrap(); + if let Some((display_name, domain)) = display_name.split_once('#') { + log::info!("Authenticating {} user {} for domain {}", body.target, display_name, domain); + let user_info = oj_rc_core::persist::user::FederatedAuthInfo { + display_name: display_name.to_owned(), + password: body.password.clone(), + domain: domain.to_owned(), + }; + let response = config.account_provider.local_login(user_info).await + .map_err(|e| { + log::error!("Failed to authenticate {} user {}#{}: {}", body.target, display_name, domain, e.message); + super::ErrorTy::from_err(e) + })?; + Ok(Json(response.response)) + } else { + Err(super::ErrorTy::from_err(oj_rc_core::persist::user::AuthError { + message: "Missing display_name domain".to_owned(), + code: oj_rc_core::data::error_codes::AuthErrorCode::InvalidDisplayName, + })) + } +} diff --git a/rc_auth/src/robocraft/intercom/services.rs b/rc_auth/src/robocraft/intercom/services.rs index d0574eb..5debfbf 100644 --- a/rc_auth/src/robocraft/intercom/services.rs +++ b/rc_auth/src/robocraft/intercom/services.rs @@ -4,7 +4,8 @@ use actix_web::{rt, web::{Payload, Data, Path, Json}, Error, HttpRequest, HttpRe #[get("/intercom/.oj_services/{name}")] pub async fn services_ws(req: HttpRequest, stream: Payload, auth: Data, reg: Data, name: Path) -> Result { - auth.validate(&req, &format!(".oj_services/{}", name))?; + log::debug!("intercom/.oj_services name is {}", name); + auth.validate(&req, &format!(".oj_services/{}", urlencoding::encode(&*name)))?; let (res, mut session, _stream) = actix_ws::handle(&req, stream)?; /*let mut stream = stream diff --git a/rc_auth/src/robocraft/mod.rs b/rc_auth/src/robocraft/mod.rs index 75a5316..0848b33 100644 --- a/rc_auth/src/robocraft/mod.rs +++ b/rc_auth/src/robocraft/mod.rs @@ -3,6 +3,7 @@ pub mod registration; pub mod steam; pub mod username; pub mod intercom; +pub mod displayname; pub struct RcConfig { //pub data: std::path::PathBuf, diff --git a/rc_core/Cargo.toml b/rc_core/Cargo.toml index 740467e..5c45e94 100644 --- a/rc_core/Cargo.toml +++ b/rc_core/Cargo.toml @@ -27,10 +27,14 @@ indexmap = { version = "2.0", features = ["serde"] } libfj.workspace = true jsonwebtoken = { version = "10", features = [ "rust_crypto" ] } argon2 = { version = "0.5", features = [ "std" ] } +# federated auth +openidconnect.workspace = true +ring = "0.17" +urlencoding.workspace = true # intercom sha2 = "0.11" -reqwest = { version = "0.13", default-features = false, features = [ "rustls", "charset", "json" ] } +reqwest = { version = "0.13", default-features = false, features = [ "rustls", "charset", "json", "form" ] } reqwest-websocket = { version = "0.6", default-features = false, features = [ "json" ] } oj_serdes.workspace = true diff --git a/rc_core/src/auth/token.rs b/rc_core/src/auth/token.rs index 4b5ef4e..d898222 100644 --- a/rc_core/src/auth/token.rs +++ b/rc_core/src/auth/token.rs @@ -7,7 +7,14 @@ pub struct Token { pub federate: bool, pub auth_time: i64, pub qualified_name: String, + pub source_domain: String, pub login_method: LoginMethod, + pub iss: String, + pub exp: i64, + pub iat: i64, + pub sub: String, + pub aud: String, + pub fedi_token: Option, } #[derive(Serialize, Deserialize, Clone, Copy)] @@ -16,4 +23,5 @@ pub enum LoginMethod { DisplayName, Username, Email, + OAuth, } diff --git a/rc_core/src/persist/config/cubes_json.rs b/rc_core/src/persist/config/cubes_json.rs index a3a4874..be727b4 100644 --- a/rc_core/src/persist/config/cubes_json.rs +++ b/rc_core/src/persist/config/cubes_json.rs @@ -327,6 +327,7 @@ impl super::ConfigProvider for CubeConfig { auth_url: self.settings.server.auth_url.trim_end_matches('/').to_owned(), intercom_url: self.settings.server.intercom_url.trim_end_matches('/').to_owned(), factory_url: self.settings.server.factory_url.trim_end_matches('/').to_owned(), + society_url: self.settings.server.society_url.trim_end_matches('/').to_owned(), minimum_version: self.settings.server.min_version as i32, dos_protect: self.settings.server.dos_protection, maintenance_message: self.settings.server.maintenance_message.clone(), diff --git a/rc_core/src/persist/config/traits.rs b/rc_core/src/persist/config/traits.rs index b791f7d..c466133 100644 --- a/rc_core/src/persist/config/traits.rs +++ b/rc_core/src/persist/config/traits.rs @@ -105,6 +105,7 @@ pub struct ServerConfig { pub auth_url: String, pub intercom_url: String, pub factory_url: String, + pub society_url: String, pub minimum_version: i32, pub dos_protect: bool, pub maintenance_message: Option, diff --git a/rc_core/src/persist/federation.rs b/rc_core/src/persist/federation.rs index 716dafa..f4b4d8a 100644 --- a/rc_core/src/persist/federation.rs +++ b/rc_core/src/persist/federation.rs @@ -23,11 +23,16 @@ fn default_aliases() -> std::collections::HashMap { alias_map.insert("rc.ngram.ca".to_owned(), "society.rc.ngram.ca".to_owned()); alias_map.insert("robocraft.online".to_owned(), "society.robocraft.online".to_owned()); alias_map.insert("robocraftgame.co.uk".to_owned(), "society.robocraftgame.co.uk".to_owned()); + alias_map.insert("127.0.0.1".to_owned(), "127.0.0.1:8002".to_owned()); alias_map } fn default_defederated() -> Vec { vec![ "robocraftgame.com".to_owned(), + #[cfg(debug_assertions)] + { "127.0.0.1:8002".to_owned() }, + #[cfg(debug_assertions)] + { "127.0.0.1".to_owned() }, ] } diff --git a/rc_core/src/persist/settings.rs b/rc_core/src/persist/settings.rs index 86fb85a..991dad5 100644 --- a/rc_core/src/persist/settings.rs +++ b/rc_core/src/persist/settings.rs @@ -111,6 +111,8 @@ pub struct ServerSettings { pub intercom_url: String, #[serde(default = "default_factory_url")] pub factory_url: String, + #[serde(default = "default_society_url")] + pub society_url: String, #[serde(default = "default_feedback_url")] pub feedback_url: String, #[serde(default = "default_support_url")] @@ -159,6 +161,7 @@ fn default_server_conf() -> ServerSettings { auth_url: default_auth_root_url(), intercom_url: default_intercom_root_url(), factory_url: default_factory_url(), + society_url: default_society_url(), feedback_url: default_feedback_url(), support_url: default_support_url(), wiki_url: default_wiki_url(), @@ -188,6 +191,10 @@ fn default_factory_url() -> String { "http://127.0.0.1:8012".to_owned() } +fn default_society_url() -> String { + "http://127.0.0.1:8002".to_owned() +} + fn default_feedback_url() -> String { "https://mstdn.ca/@ngram".to_owned() } diff --git a/rc_core/src/persist/user/account_json.rs b/rc_core/src/persist/user/account_json.rs index bd57217..051cae1 100644 --- a/rc_core/src/persist/user/account_json.rs +++ b/rc_core/src/persist/user/account_json.rs @@ -10,15 +10,14 @@ pub struct AccountProvider { fake_players: std::sync::Arc>, filler_players: std::sync::Arc>, auto_signups: bool, - domain: std::sync::Arc, + pub(super) domain: std::sync::Arc, cdn: std::sync::Arc, - auth: std::sync::Arc, + pub(super) auth: std::sync::Arc, pub(super) intercom: std::sync::Arc, pub(super) intercom_http_client: std::sync::Arc, pub(super) secret: std::sync::Arc>, - db: std::sync::Arc, - #[allow(dead_code)] - federation: Option, + pub(super) db: std::sync::Arc, + pub(super) federation: Option, } impl AccountProvider { @@ -42,7 +41,12 @@ impl AccountProvider { cdn: std::sync::Arc::new(server_settings.cdn_url), auth: std::sync::Arc::new(server_settings.auth_url), intercom: std::sync::Arc::new(server_settings.intercom_url), - intercom_http_client: std::sync::Arc::new(reqwest::Client::new()), + intercom_http_client: std::sync::Arc::new( + reqwest::ClientBuilder::new() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("HTTP client did not init") + ), secret: std::sync::Arc::new(secret), db: std::sync::Arc::new(db), federation: federation_conf, @@ -90,6 +94,7 @@ impl AccountProvider { let secret = jsonwebtoken::DecodingKey::from_secret(&self.secret); let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256); validation.set_required_spec_claims::<&str>(&[]); + validation.aud = Some(vec![ self.domain.to_string() ].into_iter().collect()); let token_data = jsonwebtoken::decode::(&token, &secret, &validation).map_err(|e| super::AuthError { message: e.to_string(), code: crate::data::error_codes::AuthErrorCode::BadCredentials, @@ -134,62 +139,10 @@ impl AccountProvider { secret: self.secret.clone(), }) } -} -#[async_trait::async_trait] -impl super::UserProvider for AccountProvider { - async fn authenticate(&self, token: super::UserToken) -> Result + Send + Sync>, super::AuthError> { - Ok(Box::new(self.auth_internal(&token.token).await?)) - } - - async fn multiplayer_authenticate(&self, user: String) -> Result + Send + Sync>, super::AuthError> { - let user_info = if let Some(user_info) = self.db.user_by_display_name(user).await.map_err(|e| super::AuthError { - message: e.to_string(), - code: crate::data::error_codes::AuthErrorCode::Unknown, - })? { - user_info - } else { - return Err(super::AuthError { - message: "User not found".to_owned(), - code: crate::data::error_codes::AuthErrorCode::BadCredentials, - }); - }; - let user_perms = if let Some(user_perms) = self.db.perms_by_user_id(user_info.id).await.map_err(|e| super::AuthError { - message: e.to_string(), - code: crate::data::error_codes::AuthErrorCode::Unknown, - })? { - user_perms - } else { - return Err(super::AuthError { - message: "User permissions not found".to_owned(), - code: crate::data::error_codes::AuthErrorCode::BadCredentials, - }); - }; - Ok(Box::new(UserData { - account: user_info, - perms: user_perms, - cubes: self.cubes.clone(), - garage_upgrades: self.garage_upgrades.clone(), - fake_players: self.fake_players.clone(), - filler_players: self.filler_players.clone(), - cdn: self.cdn.clone(), - auth: self.auth.clone(), - intercom: self.intercom.clone(), - http_client: std::sync::Arc::new(reqwest::Client::new()), - db: self.db.clone(), - secret: self.secret.clone(), - })) - } - - async fn web_authenticate(&self, token: String) -> Result, super::AuthError> { - Ok(Box::new(self.auth_internal(&token).await?)) - } -} - -#[async_trait::async_trait] -impl super::UserAuthenticator for AccountProvider { - async fn login(&self, info: super::UserAuthInfo) -> Result { + pub(super) async fn login_internal(&self, info: super::UserAuthInfo, audience: Option) -> Result { //let new_root = self.root.join(&info.payload.public_id); + let is_fedi = audience.is_some(); let is_new_user; let user_opt = match &info { super::UserAuthInfo::Steam { id } => self.db.user_by_steam_id(*id).await, @@ -204,7 +157,7 @@ impl super::UserAuthenticator for AccountProvider { user_info } else { is_new_user = true; - if self.auto_signups { + if self.auto_signups && !is_fedi { log::info!("New user {}", info.display_id()); let auto_name = match &info { super::UserAuthInfo::Steam { id } => id.to_string(), @@ -306,22 +259,31 @@ impl super::UserAuthenticator for AccountProvider { super::UserAuthInfo::Username { .. } => crate::auth::LoginMethod::Username, }; + let pub_id = if is_fedi { format!("{}#{}", user_info.public_id, self.domain) } else { user_info.public_id.clone() }; let client_details = libfj::robocraft::TokenPayload { - public_id: user_info.public_id.clone(), - display_name: user_info.display_name.clone(), - robocraft_name: user_info.display_name, + public_id: pub_id.clone(), + display_name: if is_fedi { format!("{}#{}", user_info.display_name, self.domain) } else { user_info.display_name.clone() }, + robocraft_name: if is_fedi { format!("{}#{}", user_info.public_id, self.domain) } else { user_info.public_id.clone() }, email_address: user_info.email, email_verified: true, flags: vec![ - "federated=false".to_owned(), + if is_fedi { "federated=true".to_owned() } else { "federated=false".to_owned() }, ], }; + let now = chrono::Utc::now().timestamp(); let payload = crate::auth::Token { client_details, - federate: false, - auth_time: chrono::Utc::now().timestamp(), - qualified_name: format!("{}@{}", user_info.public_id, self.domain), - login_method, + federate: is_fedi, + auth_time: now, + qualified_name: format!("{}#{}", user_info.public_id, self.domain), + source_domain: self.domain.to_string(), + login_method: if is_fedi { crate::auth::LoginMethod::OAuth } else { login_method }, + iss: self.auth.to_string(), + exp: now + 86400, // 1 day + iat: now, + sub: pub_id.clone(), + aud: audience.unwrap_or_else(|| self.domain.to_string()), + fedi_token: None, }; #[cfg(debug_assertions)] log::debug!("Token payload\n{}", serde_json::to_string_pretty(&payload).unwrap()); @@ -341,6 +303,63 @@ impl super::UserAuthenticator for AccountProvider { is_new: is_new_user, }) } +} + +#[async_trait::async_trait] +impl super::UserProvider for AccountProvider { + async fn authenticate(&self, token: super::UserToken) -> Result + Send + Sync>, super::AuthError> { + Ok(Box::new(self.auth_internal(&token.token).await?)) + } + + async fn multiplayer_authenticate(&self, user: String) -> Result + Send + Sync>, super::AuthError> { + let user_info = if let Some(user_info) = self.db.user_by_display_name(user).await.map_err(|e| super::AuthError { + message: e.to_string(), + code: crate::data::error_codes::AuthErrorCode::Unknown, + })? { + user_info + } else { + return Err(super::AuthError { + message: "User not found".to_owned(), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + }); + }; + let user_perms = if let Some(user_perms) = self.db.perms_by_user_id(user_info.id).await.map_err(|e| super::AuthError { + message: e.to_string(), + code: crate::data::error_codes::AuthErrorCode::Unknown, + })? { + user_perms + } else { + return Err(super::AuthError { + message: "User permissions not found".to_owned(), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + }); + }; + Ok(Box::new(UserData { + account: user_info, + perms: user_perms, + cubes: self.cubes.clone(), + garage_upgrades: self.garage_upgrades.clone(), + fake_players: self.fake_players.clone(), + filler_players: self.filler_players.clone(), + cdn: self.cdn.clone(), + auth: self.auth.clone(), + intercom: self.intercom.clone(), + http_client: std::sync::Arc::new(reqwest::Client::new()), + db: self.db.clone(), + secret: self.secret.clone(), + })) + } + + async fn web_authenticate(&self, token: String) -> Result, super::AuthError> { + Ok(Box::new(self.auth_internal(&token).await?)) + } +} + +#[async_trait::async_trait] +impl super::UserAuthenticator for AccountProvider { + async fn login(&self, info: super::UserAuthInfo) -> Result { + self.login_internal(info, None).await + } async fn user_exists(&self, user: super::UserId) -> Result { Ok(match user { diff --git a/rc_core/src/persist/user/federation.rs b/rc_core/src/persist/user/federation.rs index 936f5f1..d1dae72 100644 --- a/rc_core/src/persist/user/federation.rs +++ b/rc_core/src/persist/user/federation.rs @@ -1,5 +1,12 @@ +use openidconnect::{OAuth2TokenResponse, TokenResponse}; use serde::{Serialize, Deserialize}; +const SOCIETY_URLS_API_ENDPOINT: &'static str = "api/v1/services.json"; +const ACCESS_CODE_AAD: &'static [u8] = b"oj-access-code"; + +pub type DiscoveryMetadata = openidconnect::core::CoreProviderMetadata; +pub type TokenResponsePayload = openidconnect::core::CoreTokenResponse; + #[derive(Serialize, Deserialize, Clone, Debug)] pub struct Federation { pub enabled: bool, @@ -14,3 +21,557 @@ impl std::default::Default for Federation { } } } + +#[derive(Serialize, Deserialize, Clone)] +pub struct FederatedAuthenticationPayload { + pub display_name: String, + pub password: String, + pub domain_source: String, + pub domain_target: String, +} + +#[derive(Deserialize, Serialize)] +struct AccessCode { + aud: String, + exp: i64, + iat: i64, + iss: String, + sub: String, + secured: String, +} + +#[derive(Deserialize, Serialize)] +struct SecuredCodes { + access_token: String, + refresh_token: String, + code_challenge: String, +} + +struct NonceProvider { + issuer: std::sync::Arc, + secret: std::sync::Arc>, + generated_time: i64, + fuse: bool, +} + +impl NonceProvider { + fn reset(&mut self, iat: i64) { + self.generated_time = iat; + self.fuse = false; + } +} + +impl ring::aead::NonceSequence for NonceProvider { + // realistically this shouldn't ever be called again + fn advance(&mut self) -> Result { + if self.fuse { + Err(ring::error::Unspecified) + } else { + use sha2::Digest; + self.fuse = true; + let hash = sha2::Sha512::new() + .chain_update(self.issuer.as_bytes()) + .chain_update(self.secret.as_slice()) + .chain_update(&self.generated_time.to_ne_bytes()) + .finalize(); + let mut nonce = Vec::from(hash.as_slice()); + nonce.truncate(12); + Ok(ring::aead::Nonce::try_assume_unique_for_key(&nonce).unwrap()) + } + } +} + +impl super::AccountProvider { + fn nonce_provider(&self) -> NonceProvider { + NonceProvider { + issuer: self.auth.clone(), + secret: self.secret.clone(), + generated_time: 0, + fuse: true, + } + } + + fn is_defederated_from(&self, domain: &str, fedi_conf: &crate::persist::config::Federation) -> bool { + for defederated in fedi_conf.defederated.iter() { + if domain.ends_with(defederated) { + return true; + } + } + false + } + + async fn local_login_impl(&self, auth_info: super::FederatedAuthInfo, federation: &Option) -> Result { + if auth_info.display_name.is_empty() { + return Err(super::AuthError { + message: format!("Refusing federation login with empty username"), + code: crate::data::error_codes::AuthErrorCode::InvalidDisplayName, + }); + } + if let Some(fedi_conf) = federation { + if auth_info.domain.is_empty() { + return Err(super::AuthError { + message: format!("Refusing federation login with empty domain for logging in {}", auth_info.display_name), + code: crate::data::error_codes::AuthErrorCode::PasswordInvalidated, + }); + } + let sanitised_domain = auth_info.domain.trim().to_lowercase(); + let target_domain = if let Some(alias) = fedi_conf.aliases.get(&sanitised_domain) { + alias.to_owned() + } else { + sanitised_domain + }; + if self.is_defederated_from(&target_domain, fedi_conf) { + return Err(super::AuthError { + message: format!("Refusing federation with {} for logging in {}", auth_info.domain, auth_info.display_name), + code: crate::data::error_codes::AuthErrorCode::PasswordInvalidated, + }); + } + let is_localhost = target_domain == "localhost" + || target_domain == "::1" + || target_domain == "0:0:0:0:0:0:0:1" + || target_domain.starts_with("127.0.0.") + || target_domain.starts_with("localhost:"); + // TODO contact other domain + let social_urls_api = if is_localhost { + format!("http://{}/{}", target_domain, SOCIETY_URLS_API_ENDPOINT) + } else { + format!("https://{}/{}", target_domain, SOCIETY_URLS_API_ENDPOINT) + }; + let urls: oj_serdes::society::ServiceDomains = self.intercom_http_client.get(&social_urls_api).send().await + .map_err(|e| { + log::error!("Failed to get {}: {}", social_urls_api, e); + super::AuthError { + message: format!("Failed to get {} for logging in {}", social_urls_api, auth_info.display_name), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + } + })? + .json().await + .map_err(|e| { + log::error!("Failed to deserialize {}: {}", social_urls_api, e); + super::AuthError { + message: format!("Failed to deserialize {} for logging in {}", social_urls_api, auth_info.display_name), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + } + })?; + if self.is_defederated_from(&urls.root, fedi_conf) { + return Err(super::AuthError { + message: format!("Refusing federation with root {} for logging in {}", urls.root, auth_info.display_name), + code: crate::data::error_codes::AuthErrorCode::PasswordInvalidated, + }); + } + let sani_soc = urls.society.trim_start_matches("http://").trim_start_matches("https://").trim_matches('/').to_lowercase(); + if sani_soc != target_domain { + return Err(super::AuthError { + message: format!("Bad society federation with {} for logging in {}", target_domain, auth_info.display_name), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + }); + } + let issuer_url = openidconnect::IssuerUrl::new(urls.auth.clone()) + .map_err(|e| super::AuthError { + message: format!("Failed to parse issuer url {} for logging in {}: {}", urls.auth, auth_info.display_name, e), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + })?; + // openidconnect relies on an old dependency + let oauth_http_client = openidconnect::reqwest::ClientBuilder::new() + // Following redirects opens the client up to SSRF vulnerabilities. + .redirect(openidconnect::reqwest::redirect::Policy::none()) + .build() + .expect("Client should build"); + // access openid discovery endpoint to self-configure + let provider_metadata = DiscoveryMetadata::discover_async(issuer_url, &oauth_http_client).await + .map_err(|e| super::AuthError { + message: format!("Failed to discover OAuth on {} for logging in {}: {}", urls.auth, auth_info.display_name, e), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + })?; + let redirect_url_s = format!("http://{}/federation/redirect", self.domain); + let redirect_url = openidconnect::RedirectUrl::new(redirect_url_s.clone()) + .map_err(|e| super::AuthError { + message: format!("Failed to parse redirect url {} for logging in {}: {}", redirect_url_s, auth_info.display_name, e), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + })?; + // do oauth exchange + let oauth_client = openidconnect::core::CoreClient::from_provider_metadata( + provider_metadata, + openidconnect::ClientId::new(self.domain.to_string()), + None, // no client secret + ).set_redirect_uri(redirect_url); + let (pkce_challenge, pkce_verifier) = openidconnect::PkceCodeChallenge::new_random_sha256(); + let (auth_url, csrf_token, _nonce) = oauth_client + .authorize_url( + openidconnect::core::CoreAuthenticationFlow::AuthorizationCode, + openidconnect::CsrfToken::new_random, + openidconnect::Nonce::new_random, + ) + .add_scope(openidconnect::Scope::new("read".to_string())) + .add_scope(openidconnect::Scope::new("federate".to_string())) + .set_pkce_challenge(pkce_challenge) + .url(); + // bypass browser because we don't need to ask permission and there's no mechanism to open a browser + let remote_login = FederatedAuthenticationPayload { + display_name: auth_info.display_name.clone(), + password: auth_info.password.clone(), + domain_source: self.domain.to_string(), + domain_target: auth_info.domain.clone(), + }; + let auth_resp = oauth_http_client.post(auth_url) + .form(&remote_login) + .send() + .await + .map_err(|e| super::AuthError { + message: format!("Failed to authenticate federated login in for {}: {}", auth_info.display_name, e), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + })?; + let auth_redirected_url = if let Some(loc_header) = auth_resp.headers().get("location") { + let url = loc_header.to_str().map_err(|e| super::AuthError { + message: format!("Failed to stringify location header for {}: {}", auth_info.display_name, e), + code: crate::data::error_codes::AuthErrorCode::InvalidDisplayName, + })?; + reqwest::Url::parse(url).map_err(|e| super::AuthError { + message: format!("Failed to parse location URL for {}: {}", auth_info.display_name, e), + code: crate::data::error_codes::AuthErrorCode::InvalidDisplayName, + })? + } else { + return Err(super::AuthError { + message: format!("Bad OAuth2 auth response from {} for logging in {} (missing location header)", target_domain, auth_info.display_name), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + }); + }; + //let auth_redirected_url = auth_resp.url(); + log::debug!("OAuth auth response URL: {}", auth_redirected_url); + let mut query_map: std::collections::HashMap<_, _> = auth_redirected_url.query_pairs().collect(); + let auth_code = if let Some(auth_code) = query_map.remove("code") { + openidconnect::AuthorizationCode::new(auth_code.to_string()) + } else { + return Err(super::AuthError { + message: format!("Bad OAuth2 auth response from {} for logging in {} (missing code)", target_domain, auth_info.display_name), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + }); + }; + if let Some(state) = query_map.remove("state") { + if state != csrf_token.into_secret() { + return Err(super::AuthError { + message: format!("Bad OAuth2 auth response from {} for logging in {} (invalid state)", target_domain, auth_info.display_name), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + }); + } + } else { + return Err(super::AuthError { + message: format!("Bad OAuth2 auth response from {} for logging in {} (missing state)", target_domain, auth_info.display_name), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + }); + }; + let token_resp: TokenResponsePayload = oauth_client.exchange_code(auth_code) + .map_err(|e| super::AuthError { + message: format!("Failed to exchange code for {}: {}", auth_info.display_name, e), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + })? + .set_pkce_verifier(pkce_verifier) + .request_async(&oauth_http_client).await + .map_err(|e| super::AuthError { + message: format!("Failed to exchange code for {}: {}", auth_info.display_name, e), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + })?; + let _id_token = token_resp.id_token() + .ok_or_else(|| super::AuthError { + message: format!("OAuth2 Token response missing ID token for {}", auth_info.display_name), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + })?; + // Don't verify since it's not using the openidconnect::core's signing algorithm + /*let id_token_verifier = oauth_client.id_token_verifier(); + let claims = id_token.claims(&id_token_verifier, &nonce) + .map_err(|e| super::AuthError { + message: format!("Failed to verify OAuth2 claims for {}: {}", auth_info.display_name, e), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + })?; + if let Some(expected_access_token_hash) = claims.access_token_hash() { + let actual_access_token_hash = openidconnect::AccessTokenHash::from_token( + token_resp.access_token(), + id_token.signing_alg().map_err(|e| super::AuthError { + message: format!("Failed to verify OAuth2 signing algorithm for {}: {}", auth_info.display_name, e), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + })?, + id_token.signing_key(&id_token_verifier).map_err(|e| super::AuthError { + message: format!("Failed to verify OAuth2 signing key for {}: {}", auth_info.display_name, e), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + })?, + ).map_err(|e| super::AuthError { + message: format!("Failed to verify OAuth2 access token for {}: {}", auth_info.display_name, e), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + })?; + if actual_access_token_hash != *expected_access_token_hash { + return Err(super::AuthError { + message: format!("Failed to verify OAuth2 access token for {} (no match)", auth_info.display_name), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + }); + } + }*/ + let refresh_token = token_resp.refresh_token() + .ok_or_else(|| super::AuthError { + message: format!("OAuth2 Token response missing refresh token for {}", auth_info.display_name), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + })? + .secret() + .to_owned(); + let remote_token = token_resp.access_token().secret(); + #[cfg(debug_assertions)] + log::debug!("User {} authenticated to {} with access token {}", auth_info.display_name, target_domain, remote_token); + // create/update federated user entry in DB + self.update_local_database(&auth_info, &urls).await.map_err(|e| super::AuthError { + message: format!("Failed to update DB entries for {}: {}", auth_info.display_name, e), + code: crate::data::error_codes::AuthErrorCode::Unknown, + })?; + // return local success token + let local_token = self.localify_token(remote_token)?; + Ok(super::UserLoginInfo { + response: libfj::robocraft::AuthenticationResponseInfo { + token: local_token, + refresh_token: refresh_token, + refresh_token_expiry: "0".to_string(), // TODO (seems like this isn't actually considered by the client) + }, + is_new: false, + }) + } else { + Err(super::AuthError { + message: format!("Refusing federation with {} for logging in {} (federation not configured)", auth_info.domain, auth_info.display_name), + code: crate::data::error_codes::AuthErrorCode::PasswordInvalidated, + }) + } + } + + fn localify_token(&self, remote_token: &str) -> Result { + let remote_token_data = jsonwebtoken::dangerous::insecure_decode::(remote_token) + .map_err(|e| super::AuthError { + message: e.to_string(), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + })?; + let local_token_data = crate::auth::Token { + iss: self.auth.to_string(), + fedi_token: Some(remote_token.to_owned()), + ..remote_token_data.claims + }; + let header = jsonwebtoken::Header { + typ: Some("JWT".to_string()), + alg: jsonwebtoken::Algorithm::HS256, + ..Default::default() + }; + let secret = jsonwebtoken::EncodingKey::from_secret(&self.secret); + let token = jsonwebtoken::encode(&header, &local_token_data, &secret) + .unwrap_or_else(|e| { + log::error!("Failed to encode fedi JWT: {}", e); + libfj::robocraft::DEFAULT_TOKEN.to_owned() + }); + Ok(token) + } + + async fn update_local_database(&self, auth_info: &super::FederatedAuthInfo, services_info: &oj_serdes::society::ServiceDomains) -> Result<(), oj_rc_database::sea_orm::DbErr> { + use oj_rc_database::sea_orm::IntoActiveModel; + let now = chrono::Utc::now().timestamp(); + let fedi_id = if let Some(existing_fedi) = self.db.federation_by_domain(&services_info.root).await? { + let mut active = existing_fedi.into_active_model(); + active.last_used_time = oj_rc_database::sea_orm::ActiveValue::Set(now); + active.auth = oj_rc_database::sea_orm::ActiveValue::Set(services_info.auth.clone()); + active.cdn = oj_rc_database::sea_orm::ActiveValue::Set(services_info.cdn.clone()); + active.factory = oj_rc_database::sea_orm::ActiveValue::Set(services_info.factory.clone()); + active.society = oj_rc_database::sea_orm::ActiveValue::Set(services_info.society.clone()); + self.db.update_federation(active).await?.id + } else { + let new_entity = oj_rc_database::schema::federation::ActiveModel { + id: oj_rc_database::sea_orm::ActiveValue::NotSet, + creation_time: oj_rc_database::sea_orm::ActiveValue::Set(now), + last_used_time: oj_rc_database::sea_orm::ActiveValue::Set(now), + domain: oj_rc_database::sea_orm::ActiveValue::Set(services_info.root.clone()), + auth: oj_rc_database::sea_orm::ActiveValue::Set(services_info.auth.clone()), + cdn: oj_rc_database::sea_orm::ActiveValue::Set(services_info.cdn.clone()), + factory: oj_rc_database::sea_orm::ActiveValue::Set(services_info.factory.clone()), + society: oj_rc_database::sea_orm::ActiveValue::Set(services_info.society.clone()), + }; + self.db.insert_federation(new_entity).await?.id + }; + let qualified_name = format!("{}#{}", auth_info.display_name, services_info.root); + if let Some(existing_user) = self.db.user_by_display_name_and_federation(qualified_name.clone(), fedi_id).await? { + log::info!("Using existing federated user with id {} for {} from {}", existing_user.id, auth_info.display_name, auth_info.domain); + } else { + let new_id = super::initial_data::register_new_federated_user(auth_info, fedi_id, &qualified_name, self.db.as_ref()).await?; + log::info!("Created federated user with id {} for {} from {}", new_id, auth_info.display_name, auth_info.domain); + } + Ok(()) + } + + async fn remote_auth_impl(&self, auth_info: &FederatedAuthenticationPayload, challenge: &str, federation: &Option) -> Result { + if auth_info.display_name.is_empty() { + return Err(super::AuthError { + message: format!("Refusing federation login with empty username"), + code: crate::data::error_codes::AuthErrorCode::InvalidDisplayName, + }); + } + if let Some(fedi_conf) = federation { + if auth_info.domain_source.is_empty() { + return Err(super::AuthError { + message: format!("Refusing federation login with empty domain_source"), + code: crate::data::error_codes::AuthErrorCode::InvalidDisplayName, + }); + } + if auth_info.domain_target != *self.domain { + return Err(super::AuthError { + message: format!("Refusing federation login with not my domain_target"), + code: crate::data::error_codes::AuthErrorCode::InvalidDisplayName, + }); + } + if self.is_defederated_from(&auth_info.domain_source, fedi_conf) { + return Err(super::AuthError { + message: format!("Refusing federation with {} for logging in {}", auth_info.domain_source, auth_info.display_name), + code: crate::data::error_codes::AuthErrorCode::PasswordInvalidated, + }); + } + let user_info = super::UserAuthInfo::Username { + username: auth_info.display_name.clone(), + password: auth_info.password.clone(), + }; + let login_info = self.login_internal(user_info, Some(auth_info.domain_source.clone())).await?; + Ok(Self::generate_access_code( + &*self.auth, + challenge, + &auth_info.display_name, + &login_info.response.token, + &login_info.response.refresh_token, + &*self.secret, + self.nonce_provider(), + )) + } else { + Err(super::AuthError { + message: format!("Refusing federation with {} for logging in {} (federation not configured)", auth_info.domain_source, auth_info.display_name), + code: crate::data::error_codes::AuthErrorCode::PasswordInvalidated, + }) + } + } + + async fn remote_token_impl(&self, access_code: &str, verifier: &str) -> Result { + let (_code, tokens) = Self::read_access_code(access_code, &self.auth, &self.secret, self.nonce_provider()) + .map_err(|_| super::AuthError { + message: "Failed to read access code".to_owned(), + code: crate::data::error_codes::AuthErrorCode::BadCredentials, + })?; + if !Self::validate_pkce(&tokens.code_challenge, verifier) { + return Err(super::AuthError { + message: "Failed to validate PKCE".to_owned(), + code: crate::data::error_codes::AuthErrorCode::AccountUnconfirmed, + }); + } + Ok(super::UserLoginInfo { + response: libfj::robocraft::AuthenticationResponseInfo { + token: tokens.access_token, + refresh_token: tokens.refresh_token, + refresh_token_expiry: "0".to_owned(), // TODO + }, + is_new: false, + }) + } + + fn build_key(secret: &[u8], issuer: &str) -> Vec { + if secret.len() < 32 { + let mut temp_key = Vec::from(secret); + for b in issuer.bytes() { + temp_key.push(b); + if temp_key.len() >= 32 { + break; + } + } + if temp_key.len() < 32 { + for _ in temp_key.len()..32 { + temp_key.push(0); + } + } + temp_key + } else if secret.len() > 32 { + let mut temp_key = Vec::from(secret); + temp_key.truncate(32); + temp_key + } else { + Vec::from(secret) + } + } + + fn generate_access_code(issuer: &str, challenge: &str, display_name: &str, access_token: &str, refresh_token: &str, secret: &[u8], mut noncer: NonceProvider) -> String { + use ring::aead::BoundKey; + use base64::Engine; + let now = chrono::Utc::now().timestamp(); + noncer.reset(now); + let secure_data = SecuredCodes { + access_token: access_token.to_owned(), + refresh_token: refresh_token.to_owned(), + code_challenge: challenge.to_owned(), + }; + let secure_data_str = serde_json::to_string(&secure_data).unwrap(); + let key = Self::build_key(secret, issuer); + let enc_key = ring::aead::UnboundKey::new(&ring::aead::AES_256_GCM, &key).unwrap(); + let mut seal_key = ring::aead::SealingKey::new(enc_key, noncer); + let mut enc_data = Vec::from(secure_data_str.as_bytes()); + seal_key.seal_in_place_append_tag(ring::aead::Aad::from(ACCESS_CODE_AAD), &mut enc_data).unwrap(); + let secured_b64 = base64::engine::general_purpose::STANDARD.encode(enc_data); + let token_data = AccessCode { + aud: issuer.to_owned(), + exp: now + 60, // 60s + iat: now, + iss: issuer.to_owned(), + sub: display_name.to_owned(), + secured: secured_b64, + }; + let data_str = serde_json::to_string(&token_data).unwrap(); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data_str) + } + + fn read_access_code(code: &str, issuer: &str, secret: &[u8], mut noncer: NonceProvider) -> Result<(AccessCode, SecuredCodes), ()> { + use ring::aead::BoundKey; + use base64::Engine; + let json_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(code) + .map_err(|e| { + log::error!("Failed to decode Base64 access code: {}", e); + })?; + let access_code: AccessCode = serde_json::from_slice(&json_bytes) + .map_err(|e| { + log::error!("Failed to decode JSON access code: {}", e); + })?; + let mut enc_data = base64::engine::general_purpose::STANDARD.decode(&access_code.secured) + .map_err(|e| { + log::error!("Failed to decode Base64 secure code: {}", e); + })?; + noncer.reset(access_code.iat); + let key = Self::build_key(secret, issuer); + let enc_key = ring::aead::UnboundKey::new(&ring::aead::AES_256_GCM, &key).unwrap(); + let mut opening_key = ring::aead::OpeningKey::new(enc_key, noncer); + let plaintext = opening_key.open_in_place(ring::aead::Aad::from(ACCESS_CODE_AAD), &mut enc_data) + .map_err(|e| { + log::error!("Failed to decrypt secure code: {}", e); + })?; + let secure_data: SecuredCodes = serde_json::from_slice(&plaintext) + .map_err(|e| { + log::error!("Failed to decode JSON secure code: {}", e); + })?; + Ok((access_code, secure_data)) + } + + fn validate_pkce(challenge: &str, verifier: &str) -> bool { + use sha2::Digest; + use base64::Engine; + let hash = sha2::Sha256::new() + .chain_update(verifier.as_bytes()) + .finalize(); + let expected = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash); + expected == challenge + } +} + +#[async_trait::async_trait] +impl super::FederatedAuthenticator for super::AccountProvider { + async fn local_login(&self, info: super::FederatedAuthInfo) -> Result { + self.local_login_impl(info, &self.federation).await + } + + async fn remote_auth(&self, info: &FederatedAuthenticationPayload, challenge: &str) -> Result { + self.remote_auth_impl(info, challenge, &self.federation).await + } + + async fn remote_token(&self, access_code: &str, verifier: &str) -> Result { + self.remote_token_impl(access_code, verifier).await + } +} diff --git a/rc_core/src/persist/user/initial_data.rs b/rc_core/src/persist/user/initial_data.rs index 377ee01..a0cf975 100644 --- a/rc_core/src/persist/user/initial_data.rs +++ b/rc_core/src/persist/user/initial_data.rs @@ -39,6 +39,14 @@ pub async fn register_new_user(info: &super::RegistrationInfo, db: &oj_rc_databa Ok(user_data.id) } +pub async fn register_new_federated_user(info: &super::FederatedAuthInfo, fedi_id: i32, qualified_name: &str, db: &oj_rc_database::Database) -> Result { + let user_data = db.insert_user(default_fedi_user_data(info, fedi_id, qualified_name)).await?; + db.insert_perms(default_user_perms(user_data.id)).await?; + db.insert_user_aux(default_user_aux_data(user_data.id)).await?; + db.insert_garages(default_garage_slots(user_data.id)).await?; + Ok(user_data.id) +} + fn default_user_data(info: &super::RegistrationInfo) -> oj_rc_database::schema::user::ActiveModel { let password = { use argon2::password_hash::PasswordHasher; @@ -61,6 +69,32 @@ fn default_user_data(info: &super::RegistrationInfo) -> oj_rc_database::schema:: password: oj_rc_database::sea_orm::ActiveValue::Set(password), email: oj_rc_database::sea_orm::ActiveValue::Set(info.email.clone().unwrap_or_else(|| "".to_owned())), steam_id: oj_rc_database::sea_orm::ActiveValue::Set(steam_id), + federation_id: oj_rc_database::sea_orm::ActiveValue::Set(None), + } +} + +fn default_fedi_user_data(info: &super::FederatedAuthInfo, fedi_id: i32, qualified_name: &str) -> oj_rc_database::schema::user::ActiveModel { + let password = { + use argon2::password_hash::PasswordHasher; + let argon2_algo = argon2::Argon2::default(); + let salt = argon2::password_hash::SaltString::generate(&mut argon2::password_hash::rand_core::OsRng); + match argon2_algo.hash_password(info.password.as_bytes(), &salt) { + Err(e) => { + log::error!("Failed to hash password for user {}: {}", info.display_name, e); + "".to_owned() + }, + Ok(password) => password.to_string(), + } + }; + oj_rc_database::schema::user::ActiveModel { + id: Default::default(), + creation_time: oj_rc_database::sea_orm::ActiveValue::Set(current_unix_time()), + public_id: oj_rc_database::sea_orm::ActiveValue::Set(qualified_name.to_owned()), + display_name: oj_rc_database::sea_orm::ActiveValue::Set(qualified_name.to_owned()), + password: oj_rc_database::sea_orm::ActiveValue::Set(password), + email: oj_rc_database::sea_orm::ActiveValue::Set(format!("{}@{}", info.display_name, info.domain)), + steam_id: oj_rc_database::sea_orm::ActiveValue::Set(None), + federation_id: oj_rc_database::sea_orm::ActiveValue::Set(Some(fedi_id)), } } diff --git a/rc_core/src/persist/user/intercom.rs b/rc_core/src/persist/user/intercom.rs index 42408b1..dd918f9 100644 --- a/rc_core/src/persist/user/intercom.rs +++ b/rc_core/src/persist/user/intercom.rs @@ -3,9 +3,10 @@ use serde::{Serialize, Deserialize}; impl super::account_json::UserData { async fn listen_on_websocket(&self, server_name: &str) -> Result, reqwest_websocket::Error> { use reqwest_websocket::Upgrade; - let token = generate_token(format!("{}/{}", server_name, self.account.public_id).as_bytes(), &self.secret); + let url_encoded_pub_id = urlencoding::encode(&self.account.public_id); + let token = generate_token(format!("{}/{}", server_name, url_encoded_pub_id).as_bytes(), &self.secret); let auth_header_val = format!("Internal {}", token); - let url = format!("{}/intercom/{}/{}", self.intercom, server_name, self.account.public_id); + let url = format!("{}/intercom/{}/{}", self.intercom, server_name, url_encoded_pub_id); log::debug!("Listening on websocket {}", url); let websocket = self.http_client.get(url) .header("Authorization", auth_header_val) @@ -21,7 +22,7 @@ impl super::account_json::UserData { } async fn post_to_intercom(&self, data: &D, server_name: &str, operation: &str) -> Result<(), reqwest::Error> { - let path = format!("{}/{}/{}", server_name, self.account.public_id, operation); + let path = format!("{}/{}/{}", server_name, urlencoding::encode(&self.account.public_id), operation); let token = generate_token(path.as_bytes(), &self.secret); let auth_header_val = format!("Internal {}", token); let url = format!("{}/intercom/{}", self.auth, path); @@ -55,9 +56,10 @@ impl super::account_json::UserData { impl super::IntercomUser for super::account_json::UserData { async fn save_custom_avatar(&self, image: Vec) -> Result<(), polariton_server::operations::SimpleOpError> { // seems to always be jpg - let token = generate_token(self.account.public_id.as_bytes(), &self.secret); + let url_encoded_pub_id = urlencoding::encode(&self.account.public_id); + let token = generate_token(url_encoded_pub_id.as_bytes(), &self.secret); let auth_header_val = format!("Internal {}", token); - let url = format!("{}/customavatar/Live/{}", self.cdn, self.account.public_id); + let url = format!("{}/customavatar/Live/{}", self.cdn, url_encoded_pub_id); if let Err(e) = self.http_client.post(url) .header("Authorization", auth_header_val) .body(image) diff --git a/rc_core/src/persist/user/mod.rs b/rc_core/src/persist/user/mod.rs index 85fe672..22132de 100644 --- a/rc_core/src/persist/user/mod.rs +++ b/rc_core/src/persist/user/mod.rs @@ -11,7 +11,7 @@ mod inventory; pub use inventory::{UnlockedParts, UnlockOverride}; mod traits; -pub use traits::{UserProvider, User, UserToken, UserSlots, UserSlotData, VehicleData, UserAuthInfo, UserLoginInfo, UserAuthenticator, NewSlotData, UserId, RegistrationInfo, VehicleUploadData, ChatUser, AvatarInfo, GetAvatarInfo, ControlData, ControlType, CustomisationData, GetCustomisationData, SetSanction, SanctionType, LobbyUser, GameDescriptor, PlayerLobbyDescriptor, MultiplayerUser, PlayerScore, MultiplayerError, MultiplayerErrorCode, PlayerDescriptor, GameEventSetter, CurrentGameEvent, AuthError, IntercomUser, FakePlayers, ResolvedVehicle, CommonUser, IntercomListener, UserRole, SocialUser, SocialUserC, CurrencyType, CurrencyOp, MatchRewards, SingleplayerUser, PurchaseResult, FactoryUser, FriendInviteReturn, FriendData, FriendInviteStatus, SocialInfo, ClanData, ClanMember, ClanMemberRank, ClanType, ClanSearchQuery, ClanInviteData, Userless, GameOverrides, WebUser, GarageWebInfo, GarageWebStats, SanctionWebStats, AccountWebStats, SocialWebStats}; +pub use traits::{UserProvider, User, UserToken, UserSlots, UserSlotData, VehicleData, UserAuthInfo, FederatedAuthInfo, UserLoginInfo, UserAuthenticator, FederatedAuthenticator, NewSlotData, UserId, RegistrationInfo, VehicleUploadData, ChatUser, AvatarInfo, GetAvatarInfo, ControlData, ControlType, CustomisationData, GetCustomisationData, SetSanction, SanctionType, LobbyUser, GameDescriptor, PlayerLobbyDescriptor, MultiplayerUser, PlayerScore, MultiplayerError, MultiplayerErrorCode, PlayerDescriptor, GameEventSetter, CurrentGameEvent, AuthError, IntercomUser, FakePlayers, ResolvedVehicle, CommonUser, IntercomListener, UserRole, SocialUser, SocialUserC, CurrencyType, CurrencyOp, MatchRewards, SingleplayerUser, PurchaseResult, FactoryUser, FriendInviteReturn, FriendData, FriendInviteStatus, SocialInfo, ClanData, ClanMember, ClanMemberRank, ClanType, ClanSearchQuery, ClanInviteData, Userless, GameOverrides, WebUser, GarageWebInfo, GarageWebStats, SanctionWebStats, AccountWebStats, SocialWebStats}; pub mod intercom; pub use intercom::generate_token as generate_intercom_token; @@ -28,7 +28,7 @@ mod team; pub use team::{TeamChooser, StandardTeamChooser}; mod web; -mod federation; +pub mod federation; pub use federation::Federation; pub const TOKEN_SECRET_FILENAME: &str = "token_secret.key"; diff --git a/rc_core/src/persist/user/traits.rs b/rc_core/src/persist/user/traits.rs index 1e85d85..4662a99 100644 --- a/rc_core/src/persist/user/traits.rs +++ b/rc_core/src/persist/user/traits.rs @@ -22,7 +22,7 @@ pub enum UserAuthInfo { Email { email: String, password: String, - } + }, } impl UserAuthInfo { @@ -35,6 +35,12 @@ impl UserAuthInfo { } } +pub struct FederatedAuthInfo { + pub display_name: String, + pub password: String, + pub domain: String, +} + pub enum UserId { SteamId(u64), Email(String), @@ -68,13 +74,20 @@ pub trait UserProvider { } #[async_trait::async_trait] -pub trait UserAuthenticator { +pub trait UserAuthenticator: FederatedAuthenticator { async fn login(&self, info: UserAuthInfo) -> Result; async fn user_exists(&self, user: UserId) -> Result; async fn register(&self, info: RegistrationInfo) -> Result; async fn verify(&self, token: String) -> Result; } +#[async_trait::async_trait] +pub trait FederatedAuthenticator { + async fn local_login(&self, info: FederatedAuthInfo) -> Result; + async fn remote_auth(&self, info: &super::federation::FederatedAuthenticationPayload, challenge: &str) -> Result; + async fn remote_token(&self, access_token: &str, verifier: &str) -> Result; +} + #[async_trait::async_trait] pub trait User: ChatUser + SocialUser + SocialUserC + LobbyUser + MultiplayerUser + SingleplayerUser + IntercomUser + CommonUser + FactoryUser { async fn unlocked_parts(&self) -> Vec; diff --git a/rc_database/src/migration/m20260705_000001_create_federation_table.rs b/rc_database/src/migration/m20260705_000001_create_federation_table.rs new file mode 100644 index 0000000..7ef35d0 --- /dev/null +++ b/rc_database/src/migration/m20260705_000001_create_federation_table.rs @@ -0,0 +1,44 @@ +use sea_orm_migration::prelude::*; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m20260705_000001_create_federation_table" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + // Define how to apply this migration: Create the Federation table. + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(crate::schema::federation::Entity) + .col( + ColumnDef::new(crate::schema::federation::Column::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(crate::schema::federation::Column::CreationTime).big_integer().not_null()) + .col(ColumnDef::new(crate::schema::federation::Column::LastUsedTime).big_integer().not_null()) + .col(ColumnDef::new(crate::schema::federation::Column::Domain).string().not_null()) + .col(ColumnDef::new(crate::schema::federation::Column::Auth).string().not_null()) + .col(ColumnDef::new(crate::schema::federation::Column::Cdn).string().not_null()) + .col(ColumnDef::new(crate::schema::federation::Column::Factory).string().not_null()) + .col(ColumnDef::new(crate::schema::federation::Column::Society).string().not_null()) + .to_owned(), + ) + .await + } + + // Define how to rollback this migration: Drop the Federation table. + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(crate::schema::federation::Entity).to_owned()) + .await + } +} diff --git a/rc_database/src/migration/m20260705_000002_add_user_federation.rs b/rc_database/src/migration/m20260705_000002_add_user_federation.rs new file mode 100644 index 0000000..5971b15 --- /dev/null +++ b/rc_database/src/migration/m20260705_000002_add_user_federation.rs @@ -0,0 +1,69 @@ +use sea_orm_migration::prelude::*; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m20260705_000002_add_user_federation" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + // Define how to apply this migration: Add user federation id column + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // SQLite doesn't support multiple alter options in one operation + manager + .alter_table( + Table::alter() + .table(crate::schema::user::Entity) + .add_column(ColumnDef::new(crate::schema::user::Column::FederationId).integer().null()) + .to_owned() + ) + .await?; + if manager.get_connection().get_database_backend() != sea_orm::DbBackend::Sqlite { + manager + .alter_table( + Table::alter() + .table(crate::schema::user::Entity) + .add_foreign_key( + ForeignKey::create() + .name("fk-user_federation_id") + .from(crate::schema::user::Entity, crate::schema::user::Column::FederationId) + .to(crate::schema::federation::Entity, crate::schema::federation::Column::Id) + .get_foreign_key(), + ) + .to_owned() + ) + .await + } else { + // SQLite doesn't support altering foreign keys of an existing table + Ok(()) + } + + } + + // Define how to rollback this migration: Drop the added column + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // SQLite doesn't support multiple alter options in one operation + if manager.get_connection().get_database_backend() != sea_orm::DbBackend::Sqlite { + // SQLite doesn't support altering foreign keys of an existing table + manager + .alter_table( + Table::alter() + .table(crate::schema::user::Entity) + .drop_foreign_key("fk-user_federation_id") + .to_owned() + ) + .await?; + } + manager + .alter_table( + Table::alter() + .table(crate::schema::user::Entity) + .drop_column(crate::schema::user::Column::FederationId) + .to_owned() + ) + .await + } +} diff --git a/rc_database/src/migration/mod.rs b/rc_database/src/migration/mod.rs index db71424..4c0b098 100644 --- a/rc_database/src/migration/mod.rs +++ b/rc_database/src/migration/mod.rs @@ -19,6 +19,8 @@ mod m20260215_000001_create_friend_table; mod m20260221_000001_create_clan_table; mod m20260221_000002_create_clan_member_table; mod m20260317_000001_add_game_overrides; +mod m20260705_000001_create_federation_table; +mod m20260705_000002_add_user_federation; pub struct Migrator; @@ -45,6 +47,8 @@ impl MigratorTrait for Migrator { Box::new(m20260221_000001_create_clan_table::Migration), Box::new(m20260221_000002_create_clan_member_table::Migration), Box::new(m20260317_000001_add_game_overrides::Migration), + Box::new(m20260705_000001_create_federation_table::Migration), + Box::new(m20260705_000002_add_user_federation::Migration), ] } } diff --git a/rc_database/src/schema/federation.rs b/rc_database/src/schema/federation.rs new file mode 100644 index 0000000..257ad41 --- /dev/null +++ b/rc_database/src/schema/federation.rs @@ -0,0 +1,29 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "federations")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub creation_time: i64, // seconds since unix epoch + pub last_used_time: i64, // seconds since unix epoch + pub domain: String, // root domain + pub auth: String, + pub cdn: String, + pub factory: String, + pub society: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::user::Entity")] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/rc_database/src/schema/mod.rs b/rc_database/src/schema/mod.rs index 30eaffc..f365c28 100644 --- a/rc_database/src/schema/mod.rs +++ b/rc_database/src/schema/mod.rs @@ -15,6 +15,7 @@ pub mod factory; pub mod friend; pub mod clan; pub mod clan_member; +pub mod federation; pub fn parse_int_csv(s: &str) -> Vec { s.split(',').filter_map(|i_as_s| { diff --git a/rc_database/src/schema/user.rs b/rc_database/src/schema/user.rs index 73cfc00..8d0ff66 100644 --- a/rc_database/src/schema/user.rs +++ b/rc_database/src/schema/user.rs @@ -11,6 +11,7 @@ pub struct Model { pub password: String, pub email: String, pub steam_id: Option, // u64 + pub federation_id: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] @@ -29,6 +30,12 @@ pub enum Relation { FactoryUploads, #[sea_orm(has_many = "super::friend::Entity")] Friends, // this will probably join the wrong column (i.e. in the wrong direction) + #[sea_orm( + belongs_to = "super::federation::Entity", + from = "Column::FederationId", + to = "super::federation::Column::Id" + )] + Federation, } impl Related for Entity { @@ -73,4 +80,10 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::Federation.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/rc_database/src/wrapper.rs b/rc_database/src/wrapper.rs index 47b4d7e..afcc8b1 100644 --- a/rc_database/src/wrapper.rs +++ b/rc_database/src/wrapper.rs @@ -39,6 +39,17 @@ impl Database { .await } + pub async fn user_by_display_name_and_federation(&self, public_id: String, federation_id: i32) -> Result, sea_orm::DbErr> { + crate::schema::user::Entity::find() + .filter(sea_orm::sea_query::Expr::expr( + sea_orm::sea_query::Func::lower(crate::schema::user::Column::DisplayName.into_expr()) + ).eq(public_id.to_lowercase()) + ) + .filter(crate::schema::user::Column::FederationId.eq(Some(federation_id))) + .one(self.orm.as_ref()) + .await + } + pub async fn user_by_public_id(&self, public_id: String) -> Result, sea_orm::DbErr> { crate::schema::user::Entity::find() .filter(crate::schema::user::Column::PublicId.eq(public_id)) @@ -919,6 +930,31 @@ impl Database { .await } + pub async fn federation_by_id(&self, id: i32) -> Result, sea_orm::DbErr> { + crate::schema::federation::Entity::find_by_id(id) + .one(self.orm.as_ref()) + .await + } + + pub async fn federation_by_domain(&self, domain: &str) -> Result, sea_orm::DbErr> { + crate::schema::federation::Entity::find() + .filter(crate::schema::federation::Column::Id.eq(domain)) + .one(self.orm.as_ref()) + .await + } + + pub async fn insert_federation(&self, entity: crate::schema::federation::ActiveModel) -> Result { + #[cfg(debug_assertions)] + assert!(matches!(entity.id, sea_orm::ActiveValue::NotSet)); + entity.insert(self.orm.as_ref()).await + } + + pub async fn update_federation(&self, entity: crate::schema::federation::ActiveModel) -> Result { + crate::schema::federation::Entity::update(entity) + .exec(self.orm.as_ref()) + .await + } + pub async fn metrics(&self) -> super::DatabaseMetrics { self.metrics.lock().unwrap().snapshot() } diff --git a/rc_society/Cargo.toml b/rc_society/Cargo.toml index b3c0346..ff8efaa 100644 --- a/rc_society/Cargo.toml +++ b/rc_society/Cargo.toml @@ -25,6 +25,7 @@ oj_rc_core = { version = "*", path = "../rc_core" } oj_rc_plugins = { version = "*", path = "../rc_plugins" } oj_rc_factory = { version = "*", path = "../rc_factory" } oj_convert.workspace = true +oj_serdes.workspace = true libfj.workspace = true git-version.workspace = true serde.workspace = true diff --git a/rc_society/src/api/mod.rs b/rc_society/src/api/mod.rs index b7279ab..37948f8 100644 --- a/rc_society/src/api/mod.rs +++ b/rc_society/src/api/mod.rs @@ -1,6 +1,8 @@ pub mod garage; pub mod config; +pub mod urls; pub fn init(config: &dyn oj_rc_core::ConfigProvider<()>) { config::init(config); + urls::init(config); } diff --git a/rc_society/src/api/urls.rs b/rc_society/src/api/urls.rs new file mode 100644 index 0000000..4e7088d --- /dev/null +++ b/rc_society/src/api/urls.rs @@ -0,0 +1,25 @@ +use actix_web::{HttpResponse, Responder, get, http::header::ContentType}; + +static JSON_DATA: std::sync::OnceLock = std::sync::OnceLock::new(); + +pub(super) fn init(config: &dyn oj_rc_core::ConfigProvider<()>) { + JSON_DATA.get_or_init(|| { + let urls = config.server_config(); + let data = oj_serdes::society::ServiceDomains { + root: urls.domain, + auth: urls.auth_url, + cdn: urls.cdn_url, + factory: urls.factory_url, + society: urls.society_url, + }; + serde_json::to_string(&data).expect("ServiceDomains did not serialize") + }); +} + +#[get("/api/v1/services.json")] +pub async fn get() -> impl Responder { + let data = JSON_DATA.get().expect("ServiceDomains JSON init failure").to_owned(); + HttpResponse::Ok() + .insert_header(ContentType::json()) + .body(data) +} diff --git a/rc_society/src/main.rs b/rc_society/src/main.rs index f579e42..2672b45 100644 --- a/rc_society/src/main.rs +++ b/rc_society/src/main.rs @@ -104,6 +104,7 @@ async fn main() -> std::io::Result<()> { .service(web::user_federation::post_off) .service(web::user_federation::post_on) .service(api::config::get) + .service(api::urls::get) }) .bind((cli_args.ip, cli_args.port))? .run()