From 148534c1175802e048fbfd12589bcd74ecf13311 Mon Sep 17 00:00:00 2001 From: Marek Fajkus Date: Thu, 31 Aug 2023 21:46:36 +0200 Subject: [PATCH] Orca: Integrate with keycloak (#84) - fetch jwk from keycloak (depending on config) - validate JWTs issued by keycloak based on JWKs - verify resource roles from keycloak - implement basic stats api using keycloak authorization --- .github/workflows/acceptance-test.yaml | 2 + administration-panel/src/keycloak.ts | 2 +- .../migrations/V8__add_registration_views.sql | 46 +++++ orca/Cargo.lock | 180 +++++++++++++++--- orca/Cargo.toml | 5 +- orca/Rocket.example.toml | 5 + orca/default.nix | 2 +- orca/src/api/applications/mod.rs | 20 ++ orca/src/api/mod.rs | 38 ++++ orca/src/api/stats/mod.rs | 47 +++++ orca/src/api/stats/query.rs | 33 ++++ orca/src/config.rs | 13 ++ orca/src/main.rs | 27 ++- orca/src/processing/mod.rs | 2 +- orca/src/server/jwk.rs | 55 ++++++ orca/src/server/keycloak.rs | 163 ++++++++++++++++ orca/src/server/mod.rs | 3 + scripts/README.md | 9 + scripts/get-token.sh | 27 +++ 19 files changed, 648 insertions(+), 31 deletions(-) create mode 100644 gray-whale/migrations/V8__add_registration_views.sql create mode 100644 orca/src/api/applications/mod.rs create mode 100644 orca/src/api/stats/mod.rs create mode 100644 orca/src/api/stats/query.rs create mode 100644 orca/src/server/jwk.rs create mode 100644 orca/src/server/keycloak.rs create mode 100644 scripts/README.md create mode 100755 scripts/get-token.sh diff --git a/.github/workflows/acceptance-test.yaml b/.github/workflows/acceptance-test.yaml index 8f1ff53..59ba24b 100644 --- a/.github/workflows/acceptance-test.yaml +++ b/.github/workflows/acceptance-test.yaml @@ -57,6 +57,8 @@ jobs: ROCKET_SMTP_HOST: "localhost" ROCKET_SMTP_USER: "" ROCKET_SMTP_PASSWORD: "" + ROCKET_KEYCLOAK_HOST: "https://keycloak.ictunion.cz" + ROCKET_KEYCLOAK_REALM: "testing-members" - name: Check status of orca run: | diff --git a/administration-panel/src/keycloak.ts b/administration-panel/src/keycloak.ts index 344c32f..8f524c8 100644 --- a/administration-panel/src/keycloak.ts +++ b/administration-panel/src/keycloak.ts @@ -1,5 +1,5 @@ import Keycloak from 'keycloak-js'; -import config, { Url } from './config'; +import config from './config'; export interface UserInfo { name: string, diff --git a/gray-whale/migrations/V8__add_registration_views.sql b/gray-whale/migrations/V8__add_registration_views.sql new file mode 100644 index 0000000..c821028 --- /dev/null +++ b/gray-whale/migrations/V8__add_registration_views.sql @@ -0,0 +1,46 @@ +-- Make members number unique +ALTER TABLE members ADD UNIQUE (member_number); + +-- Add rejceted at column to registration requests +ALTER TABLE registration_requests + ADD COLUMN rejected_at TIMESTAMPTZ; + +COMMENT ON COLUMN registration_requests.rejected_at IS 'Time when request was rejected. If NULL then it was never rejected'; + +-- Create views + +CREATE OR REPLACE VIEW registration_requests_unverified AS + SELECT * FROM registration_requests rr + WHERE rr.confirmed_at IS NULL + AND rr.rejected_at IS NULL + AND NOT EXISTS (SELECT id FROM members m WHERE rr.id = m.registration_request_id); + +COMMENT ON VIEW registration_requests_unverified IS 'All registration requests which are waiting on email confirmation'; + +GRANT SELECT ON registration_requests_unverified TO orca; + +CREATE OR REPLACE VIEW registration_requests_accepted AS + SELECT * FROM registration_requests rr + WHERE EXISTS (SELECT id FROM members m WHERE rr.id = m.registration_request_id); + +COMMENT ON VIEW registration_requests_accepted IS 'All registration requests that were accepted (as members)'; + +GRANT SELECT ON registration_requests_accepted TO orca; + +CREATE OR REPLACE VIEW registration_requests_rejected AS + SELECT * FROM registration_requests rr + WHERE rr.rejected_at IS NOT NULL; + +COMMENT ON VIEW registration_requests_rejected IS 'All registration requests that were rejected'; + +GRANT SELECT ON registration_requests_rejected TO orca; + +CREATE OR REPLACE VIEW registration_requests_procession AS + SELECT * FROM registration_requests rr + WHERE rr.confirmed_at IS NOT NULL + AND rr.rejected_at IS NULL + AND NOT EXISTS (SELECT id FROM members m WHERE rr.id = m.registration_request_id); + +COMMENT ON VIEW registration_requests_procession IS 'All registrations which are confirmed by applicant but are not yet either rejected or accepted'; + +GRANT SELECT ON registration_requests_procession TO orca; diff --git a/orca/Cargo.lock b/orca/Cargo.lock index f6f705a..0e0e56f 100644 --- a/orca/Cargo.lock +++ b/orca/Cargo.lock @@ -85,7 +85,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.29", ] [[package]] @@ -96,7 +96,7 @@ checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842" dependencies = [ "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.29", ] [[package]] @@ -489,7 +489,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.18", + "syn 2.0.29", ] [[package]] @@ -1040,6 +1040,19 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "iana-time-zone" version = "0.1.57" @@ -1145,6 +1158,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ipnet" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6" + [[package]] name = "ipnetwork" version = "0.19.0" @@ -1205,6 +1224,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "8.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378" +dependencies = [ + "base64 0.21.2", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -1561,7 +1594,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.29", ] [[package]] @@ -1591,13 +1624,16 @@ dependencies = [ "fern", "handlebars", "image", + "jsonwebtoken", "lettre", "log", "phf", "rand", + "reqwest", "rocket", "rocket-validation", "rustc-serialize", + "serde", "sqlx", "time 0.3.22", "tokio", @@ -1685,7 +1721,16 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.18", + "syn 2.0.29", +] + +[[package]] +name = "pem" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8835c273a76a90455d7344889b0964598e3316e2a79ede8e36f16bdcf2228b8" +dependencies = [ + "base64 0.13.1", ] [[package]] @@ -1724,7 +1769,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.29", ] [[package]] @@ -1797,7 +1842,7 @@ checksum = "39407670928234ebc5e6e580247dd567ad73a3578460c5990f9503df207e8f07" dependencies = [ "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.29", ] [[package]] @@ -1872,9 +1917,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.60" +version = "1.0.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec2b086b7a862cf4de201096214fa870344cf922b2b30c167badb3af3195406" +checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" dependencies = [ "unicode-ident", ] @@ -1887,7 +1932,7 @@ checksum = "606c4ba35817e2922a308af55ad51bab3645b59eae5c570d4a6cf07e36bd493b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.29", "version_check", "yansi", ] @@ -2040,7 +2085,7 @@ checksum = "8d2275aab483050ab2a7364c1a46604865ee7d6906684e08db0f090acf74f9e7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.29", ] [[package]] @@ -2084,6 +2129,43 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde824a14b7c14f85caff81225f411faacc04a2013f41670f41443742b1c1c55" +dependencies = [ + "base64 0.21.2", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "ring" version = "0.16.20" @@ -2188,7 +2270,7 @@ dependencies = [ "proc-macro2", "quote", "rocket_http", - "syn 2.0.18", + "syn 2.0.29", "unicode-xid", ] @@ -2352,29 +2434,29 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.164" +version = "1.0.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e8c8cf938e98f769bc164923b06dce91cea1751522f46f8466461af04c9027d" +checksum = "32ac8da02677876d532745a130fc9d8e6edfa81a269b107c5b00829b91d8eb3c" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.164" +version = "1.0.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9735b638ccc51c28bf6914d90a2e9725b377144fc612c49a611fddd1b631d68" +checksum = "aafe972d60b0b9bee71a91b92fee2d4fb3c9d7e8f6b179aa99f27203d99a4816" dependencies = [ "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.29", ] [[package]] name = "serde_json" -version = "1.0.97" +version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdf3bf93142acad5821c99197022e170842cdbc1c30482b98750c688c640842a" +checksum = "693151e1ac27563d6dbcec9dee9fbd5da8539b20fa14ad3752b2e6d363ace360" dependencies = [ "itoa", "ryu", @@ -2390,6 +2472,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.10.5" @@ -2442,6 +2536,18 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a" +[[package]] +name = "simple_asn1" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time 0.3.22", +] + [[package]] name = "siphasher" version = "0.3.10" @@ -2651,9 +2757,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.18" +version = "2.0.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e" +checksum = "c324c494eba9d92503e6f1ef2e6df781e78f6a7705a0202d9801b198807d518a" dependencies = [ "proc-macro2", "quote", @@ -2697,7 +2803,7 @@ checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.29", ] [[package]] @@ -2800,7 +2906,7 @@ checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.29", ] [[package]] @@ -2918,7 +3024,7 @@ checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab" dependencies = [ "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.29", ] [[package]] @@ -3060,6 +3166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa2982af2eec27de306107c027578ff7f423d65f7250e40ce0fea8f45248b81" dependencies = [ "getrandom", + "serde", ] [[package]] @@ -3164,10 +3271,22 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.29", "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c02dbc21516f9f1f04f187958890d7e6026df8d16540b7ad9492bc34a67cea03" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.87" @@ -3186,7 +3305,7 @@ checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.29", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3405,6 +3524,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "wyz" version = "0.5.1" diff --git a/orca/Cargo.toml b/orca/Cargo.toml index db9af23..8ec7af4 100644 --- a/orca/Cargo.toml +++ b/orca/Cargo.toml @@ -9,6 +9,7 @@ proxy-support = [] [dependencies] rand = "0.8.5" rocket = { version = "0.5.0-rc.2", features = [ "json" ] } +serde = { version = "1.0.147", features = ["derive"] } rocket-validation = "0.1.3" validator = "0.16.0" sqlx = { version = "0.6.2", features = [ "runtime-tokio-rustls", "postgres", "macros", "all-types", "uuid" ] } @@ -20,7 +21,9 @@ cfg-if = "1.0.0" log = "0.4.17" fern = "0.6.1" chrono = "0.4.24" -uuid = { version = "1.3.0", features = [ "v4" ] } +uuid = { version = "1.3.0", features = [ "v4", "serde" ] } phf = { version = "0.11.1", features = [ "macros" ] } lettre = { version = "0.10.4", features = [ "tokio1", "tokio1-native-tls" ] } handlebars = "4.3.7" +reqwest = { version = "0.11.18", features = [ "json" ] } +jsonwebtoken = "8.3.0" diff --git a/orca/Rocket.example.toml b/orca/Rocket.example.toml index 3a5ea67..268ceba 100644 --- a/orca/Rocket.example.toml +++ b/orca/Rocket.example.toml @@ -28,6 +28,11 @@ processing_db_pool = 2 # PDF printing tex_exe = "xelatex" +# Authentication / Authorization +keycloak_host = "https://keycloak.ictunion.cz" +keycloak_realm = "testing-members" +keycloak_client_id = "orca" + # Business logic configuration processing_queue_size = 16 diff --git a/orca/default.nix b/orca/default.nix index 9eae728..12d13ab 100644 --- a/orca/default.nix +++ b/orca/default.nix @@ -19,7 +19,7 @@ rustPlatform.buildRustPackage { pname = "ict-union-orca"; version = "0.1.0"; src = nix-gitignore.gitignoreSource [] ./.; - cargoSha256 = "sha256-13fJemyyH2PJ0U0BFg4YLw5BXqw8P0CObIB7RLmzpLU="; + cargoSha256 = "sha256-TcQ26NRmd7aCok2WvRWZBmLGNI2bmQZEqxwiNT1ys9o="; nativeBuildInputs = [ pkg-config makeWrapper ]; diff --git a/orca/src/api/applications/mod.rs b/orca/src/api/applications/mod.rs new file mode 100644 index 0000000..2f06932 --- /dev/null +++ b/orca/src/api/applications/mod.rs @@ -0,0 +1,20 @@ +use rocket::{Route, State}; + +use super::Response; +use crate::db::DbPool; +use crate::server::keycloak::{JwtToken, Keycloak}; + +#[get("/test", format = "json")] +async fn test_api<'r>( + _db_pool: &State, + keycloak: &State, + token: JwtToken<'r>, +) -> Response { + let res = keycloak.inner().decode_jwt(token); + + Ok(format!("{:?}", res)) +} + +pub fn routes() -> Vec { + routes![test_api] +} diff --git a/orca/src/api/mod.rs b/orca/src/api/mod.rs index 45b33ee..cdc03b4 100644 --- a/orca/src/api/mod.rs +++ b/orca/src/api/mod.rs @@ -3,7 +3,10 @@ use rocket::{Build, Request, Rocket}; use tokio::task::JoinError; use validator::ValidationError; +mod applications; mod registration; +mod stats; + use crate::processing::SenderError; #[derive(Debug)] @@ -49,12 +52,39 @@ impl<'r> response::Responder<'r, 'static> for SenderError { } } +#[derive(Debug)] +pub struct JwtError(jsonwebtoken::errors::Error); + +impl<'r> Responder<'r, 'static> for JwtError { + fn respond_to(self, _request: &'r Request<'_>) -> response::Result<'static> { + use jsonwebtoken::errors::ErrorKind; + use rocket::http::Status; + + let kind = self.0.kind(); + + info!("JWT verification failed with {:?}", kind); + match kind { + ErrorKind::ExpiredSignature => Err(Status::Forbidden), + _ => Err(Status::Unauthorized), + } + } +} + #[derive(Debug, Responder)] pub enum ApiError { + #[response(status = 500)] DbErr(SqlError), Status(rocket::http::Status), + #[response(status = 500)] ThreadFail(ThreadingError), + #[response(status = 500)] QueueSender(SenderError), + #[response(status = 401)] + InvalidToken(JwtError), + #[response(status = 403)] + MissingRole(String), + #[response(status = 401)] + AuthorizationDisabled(()), } impl From for ApiError { @@ -81,6 +111,12 @@ impl From for ApiError { } } +impl From for ApiError { + fn from(err: jsonwebtoken::errors::Error) -> Self { + Self::InvalidToken(JwtError(err)) + } +} + #[get("/status")] fn status_api() -> SuccessResponse { SuccessResponse::Ok @@ -90,6 +126,8 @@ pub fn build() -> Rocket { rocket::build() .mount("/", routes![status_api]) .mount("/registration", registration::routes()) + .mount("/applications", applications::routes()) + .mount("/stats", stats::routes()) .register( "/registration", catchers![rocket_validation::validation_catcher], diff --git a/orca/src/api/stats/mod.rs b/orca/src/api/stats/mod.rs new file mode 100644 index 0000000..b0b87a4 --- /dev/null +++ b/orca/src/api/stats/mod.rs @@ -0,0 +1,47 @@ +use rocket::serde::json::Json; +use rocket::{serde::Serialize, Responder, Route, State}; + +use rocket::http::{ContentType, Header}; + +use super::Response; +use crate::db::DbPool; +use crate::server::keycloak::{JwtToken, Keycloak}; + +mod query; + +#[derive(Debug, Serialize)] +struct BasicStats { + unverified: i64, + accepted: i64, + rejected: i64, + processing: i64, +} + +#[get("/basic")] +async fn basic_stats<'r>( + db_pool: &State, + keycloak: &State, + token: JwtToken<'r>, +) -> Response> { + // Every authenticated user is able to see stats + let _ = keycloak.inner().decode_jwt(token); + + let (unverified,) = query::count_unverified().fetch_one(db_pool.inner()).await?; + + let (accepted,) = query::count_accepted().fetch_one(db_pool.inner()).await?; + + let (rejected,) = query::count_rejected().fetch_one(db_pool.inner()).await?; + + let (processing,) = query::count_processing().fetch_one(db_pool.inner()).await?; + + Ok(Json(BasicStats { + unverified, + accepted, + rejected, + processing, + })) +} + +pub fn routes() -> Vec { + routes![basic_stats] +} diff --git a/orca/src/api/stats/query.rs b/orca/src/api/stats/query.rs new file mode 100644 index 0000000..3ae0c53 --- /dev/null +++ b/orca/src/api/stats/query.rs @@ -0,0 +1,33 @@ +use crate::db::QueryAs; + +pub fn count_unverified<'a>() -> QueryAs<'a, (i64,)> { + sqlx::query_as( + " +SELECT count(*) FROM registration_requests_unverified +", + ) +} + +pub fn count_accepted<'a>() -> QueryAs<'a, (i64,)> { + sqlx::query_as( + " +SELECT count(*) FROM registration_requests_accepted +", + ) +} + +pub fn count_rejected<'a>() -> QueryAs<'a, (i64,)> { + sqlx::query_as( + " +SELECT count(*) FROM registration_requests_rejected +", + ) +} + +pub fn count_processing<'a>() -> QueryAs<'a, (i64,)> { + sqlx::query_as( + " +SELECT count(*) FROM registration_requests_procession +", + ) +} diff --git a/orca/src/config.rs b/orca/src/config.rs index fabe7cf..58a0aaf 100644 --- a/orca/src/config.rs +++ b/orca/src/config.rs @@ -26,6 +26,9 @@ pub struct Config { pub smtp_host: String, pub smtp_user: String, pub smtp_password: String, + pub keycloak_host: Option, + pub keycloak_realm: Option, + pub keycloak_client_id: Option, pub templates: templates::Templates<'static>, } @@ -89,6 +92,13 @@ impl Config { // This makes app fail at startup in case there is some error which we want templates.preload_templates(&templates_path).unwrap(); + // read keycloak settings + let keycloak_host: Option = figment.extract_inner("keycloak_host").ok(); + + let keycloak_realm: Option = figment.extract_inner("keycloak_realm").ok(); + + let keycloak_client_id: Option = figment.extract_inner("keycloak_client_id").ok(); + Self { email_sender_email, email_sender_name, @@ -105,6 +115,9 @@ impl Config { smtp_host, smtp_user, smtp_password, + keycloak_host, + keycloak_realm, + keycloak_client_id, templates, } } diff --git a/orca/src/main.rs b/orca/src/main.rs index c1b5dad..ab37f6f 100644 --- a/orca/src/main.rs +++ b/orca/src/main.rs @@ -10,6 +10,8 @@ //! ``` //! cargo build --features proxy-support //! ``` + +use server::keycloak::{self, Keycloak}; extern crate rand; #[macro_use] extern crate rocket; @@ -25,8 +27,10 @@ extern crate validator; extern crate log; extern crate chrono; extern crate fern; +extern crate handlebars; +extern crate jsonwebtoken; extern crate phf; -// extern crate handlebars; +extern crate reqwest; mod api; mod config; @@ -43,6 +47,7 @@ enum StartupError { Database(db::SqlError), Server(rocket::Error), Logger(fern::InitError), + Keyclaok(server::keycloak::Error), } impl From for StartupError { @@ -63,6 +68,12 @@ impl From for StartupError { } } +impl From for StartupError { + fn from(value: server::keycloak::Error) -> Self { + Self::Keyclaok(value) + } +} + #[rocket::main] async fn main() -> Result<(), StartupError> { // Read cofiguration @@ -71,6 +82,19 @@ async fn main() -> Result<(), StartupError> { // Configure logger logging::setup_logger(config.log_level)?; + // Configure authorization + // poor man's applicative functor + let keycloak = match ( + &config.keycloak_host, + &config.keycloak_realm, + &config.keycloak_client_id, + ) { + (Some(host), Some(realm), Some(client_id)) => { + server::keycloak::Keycloak::fetch(host, realm, client_id.clone()).await? + } + _ => Keycloak::disable(), + }; + let web_db_pool = db::connect(db::Config { connection_url: &config.postgres, max_connections: config.web_db_pool, @@ -89,6 +113,7 @@ async fn main() -> Result<(), StartupError> { .manage(web_db_pool) .manage(queue) .manage(config) + .manage(keycloak) .launch() .await?; diff --git a/orca/src/processing/mod.rs b/orca/src/processing/mod.rs index 20b1397..a5a8d5f 100644 --- a/orca/src/processing/mod.rs +++ b/orca/src/processing/mod.rs @@ -175,7 +175,7 @@ async fn process( MultiPart::related() .singlepart(SinglePart::html(message_html)) .singlepart( - Attachment::new(String::from(pdf_name)) + Attachment::new(pdf_name) // This should never fail // we generate the pdf ourselves so we know it will be valid .body(pdf_data, "application/pdf".parse().unwrap()), diff --git a/orca/src/server/jwk.rs b/orca/src/server/jwk.rs new file mode 100644 index 0000000..35425a8 --- /dev/null +++ b/orca/src/server/jwk.rs @@ -0,0 +1,55 @@ +use jsonwebtoken::DecodingKey; +use reqwest; +use serde::{Deserialize, Serialize}; + +// Define a struct to represent the Keycloak JSON Web Key Set (JWKS) +#[derive(Debug, Serialize, Deserialize)] +struct Jwks { + keys: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct Jwk { + alg: String, + kty: String, + #[serde(rename = "use")] + use_: String, + n: String, + e: String, + kid: String, + x5c: Option>, +} + +#[derive(Debug)] +pub enum Error { + FaildToGetCerts(reqwest::Error), + SignatureKeyMissing, + InvalidKey(jsonwebtoken::errors::Error), +} + +impl From for Error { + fn from(value: reqwest::Error) -> Self { + Self::FaildToGetCerts(value) + } +} + +impl From for Error { + fn from(value: jsonwebtoken::errors::Error) -> Self { + Self::InvalidKey(value) + } +} + +pub async fn fetch_jwk(jwks_url: &str) -> Result { + let jwks: Jwks = reqwest::get(jwks_url).await?.json().await?; + + // Find signature key + let jwk: &Jwk = jwks + .keys + .iter() + .find(|&key| key.use_ == "sig") + .ok_or(Error::SignatureKeyMissing)?; + + let decoding_key = DecodingKey::from_rsa_components(&jwk.n, &jwk.e)?; + + Ok(decoding_key) +} diff --git a/orca/src/server/keycloak.rs b/orca/src/server/keycloak.rs new file mode 100644 index 0000000..051712a --- /dev/null +++ b/orca/src/server/keycloak.rs @@ -0,0 +1,163 @@ +use jsonwebtoken::{self, Algorithm, DecodingKey, TokenData, Validation}; +use std::collections::HashMap; + +use rocket::http::Status; +use rocket::request::{FromRequest, Outcome, Request}; +use rocket::serde::Deserialize; +use rocket::serde::Serialize; + +use crate::api::ApiError; + +use super::jwk; + +struct ConnectedKeycloak { + key: DecodingKey, + validation: Validation, + client_id: String, +} + +impl ConnectedKeycloak { + pub async fn fetch(host: &str, realm: &str, client_id: String) -> Result { + let key = jwk::fetch_jwk(&format!( + "{}/protocol/openid-connect/certs", + keycloak_url(host, realm) + )) + .await?; + + // Configure validations + let mut validation = Validation::new(Algorithm::RS256); + validation.set_issuer(&[keycloak_url(host, realm)]); + + Ok(Self { + key, + validation, + client_id, + }) + } + + pub fn decode_jwt( + &self, + token: JwtToken, + ) -> Result, jsonwebtoken::errors::Error> { + jsonwebtoken::decode::(token.0, &self.key, &self.validation) + } + + pub fn require_role( + &self, + token: JwtToken, + role: &str, + ) -> Result, ApiError> { + let token_data = self.decode_jwt(token)?; + if self.has_role(&token_data.claims, role) { + Ok(token_data) + } else { + Err(ApiError::MissingRole(role.to_string())) + } + } + + fn has_role(&self, claims: &JwtClaims, role: &str) -> bool { + match claims.resource_access.get(&self.client_id) { + Some(r) => r.roles.iter().any(|x| *x == role), + None => false, + } + } +} + +pub enum Keycloak { + Connected(Box), + Disconnected, +} + +impl Keycloak { + pub async fn fetch(host: &str, realm: &str, client_id: String) -> Result { + let k = ConnectedKeycloak::fetch(host, realm, client_id).await?; + Ok(Self::Connected(Box::new(k))) + } + + pub fn disable() -> Self { + warn!("Keycloak authorization not configured. Authorization disabled"); + Self::Disconnected + } + + pub fn decode_jwt(&self, token: JwtToken) -> Result, Error> { + match self { + Self::Connected(k) => { + let res = k.decode_jwt(token)?; + Ok(res) + } + Self::Disconnected => Err(Error::Disabled), + } + } + + pub fn require_role( + &self, + token: JwtToken, + role: &str, + ) -> Result, ApiError> { + match self { + Self::Connected(k) => k.require_role(token, role), + Self::Disconnected => Err(ApiError::AuthorizationDisabled(())), + } + } +} + +#[derive(Debug)] +pub enum Error { + BadKey(jwk::Error), + BadToken(jsonwebtoken::errors::Error), + Disabled, +} + +impl From for Error { + fn from(value: jwk::Error) -> Self { + Self::BadKey(value) + } +} + +impl From for Error { + fn from(value: jsonwebtoken::errors::Error) -> Self { + Self::BadToken(value) + } +} + +#[derive(Debug, Deserialize, Serialize)] +struct Roles { + roles: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct JwtClaims { + sub: uuid::Uuid, + realm_access: Roles, + resource_access: HashMap, +} + +pub struct JwtToken<'a>(&'a str); + +#[derive(Responder, Debug)] +pub enum NetworkResponse { + #[response(status = 401)] + Unauthorized(String), +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for JwtToken<'r> { + type Error = NetworkResponse; + + async fn from_request(req: &'r Request<'_>) -> Outcome { + match req.headers().get_one("authorization") { + None => Outcome::Failure(( + Status::Unauthorized, + NetworkResponse::Unauthorized("Expects authorization header".to_string()), + )), + Some(string) => { + let token = string.trim_start_matches("Bearer").trim(); + Outcome::Success(JwtToken(token)) + } + } + } +} + +fn keycloak_url(host: &str, realm: &str) -> String { + format!("{host}/realms/{realm}") +} diff --git a/orca/src/server/mod.rs b/orca/src/server/mod.rs index 435e7f3..3e3a555 100644 --- a/orca/src/server/mod.rs +++ b/orca/src/server/mod.rs @@ -2,6 +2,9 @@ use core::fmt::Display; use std::convert::Infallible; use std::net::IpAddr; +pub mod jwk; +pub mod keycloak; + cfg_if::cfg_if! { if #[cfg(feature="proxy-support")] { use std::str::FromStr; diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..5768568 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,9 @@ +# (Handy) Scripts + +Ad-hoc scripts that might simplify you running some common tasks during development or debugging + +**Scripts require nix for provisioning of dependecies** + +## get-token + +Obtain token from `testing-members` realm of https://keycloak.ictunion.cz for given client and user (using password based authentication). diff --git a/scripts/get-token.sh b/scripts/get-token.sh new file mode 100755 index 0000000..17b299d --- /dev/null +++ b/scripts/get-token.sh @@ -0,0 +1,27 @@ +#! /usr/bin/env nix-shell +#! nix-shell -i bash -p curl jq + +HOST=https://keycloak.ictunion.cz +REALM=testing-members + +>&2 echo " This script will return acess token to testing-members instance" +read -p "Enter client-id of your app: " CLIENTID +read -p "Enter user name: " USERNAME +read -s -p "Enter password: " PWD +>&2 echo "" + +RESPONSE=$(curl --silent -d "grant_type=password&client_id=$CLIENTID&username=$USERNAME&password=$PWD" "$HOST/realms/$REALM/protocol/openid-connect/token") + +>&2 echo "API Response is" +>&2 echo $RESPONSE +>&2 echo "" + +ACCESS_TOKEN=$(echo $RESPONSE | jq -r '.access_token') +>&2 echo "Access token is" +>&2 echo $ACCESS_TOKEN +>&2 echo "" + +>&2 echo "Attempting to copy token to clipboard" +echo $ACCESS_TOKEN | xclip -sel clip 2> /dev/null # Linux X11 +echo $ACCESS_TOKEN | clip.exe 2> /dev/null # Windows +echo $ACCESS_TOKEN | pbcopy 2> /dev/null # Macos