From 31486a9d5e2aa476fa4190203ea0f6f97d7dc7bd Mon Sep 17 00:00:00 2001 From: Denys Konovalov Date: Sun, 28 Aug 2022 17:13:43 +0200 Subject: [PATCH] Change authentication/authorization flow, code cleanup, severa fixes --- Cargo.toml | 9 +- README.md | 12 +- docker-compose.yml | 10 +- src/config.rs | 12 +- src/indiware_connector.rs | 500 +++++++++++--------------------------- src/keycloak_connector.rs | 282 --------------------- src/main.rs | 192 ++++++++++----- 7 files changed, 278 insertions(+), 739 deletions(-) delete mode 100755 src/keycloak_connector.rs diff --git a/Cargo.toml b/Cargo.toml index 161d10d..560d5e8 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,19 +1,16 @@ [package] name = "api" -version = "1.0.0" +version = "2.0.0-alpha.1" edition = "2018" license = "AGPL-3.0-or-later" -authors = ["Denys Konovalov "] +authors = ["Denys Konovalov "] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -rocket = { version = "0.5.0-rc.1", features = ["json"] } +rocket = { version = "0.5.0-rc.2", features = ["json"] } serde = "1.0" reqwest = { version="0.11", features = ["json"] } quickxml_to_serde = "0.5" serde_json = "1.0" serde_derive = "1.0" -jsonwebtoken = "8.1" -time = "0.3" -chrono = "0.4" diff --git a/README.md b/README.md index fba8d16..5c47035 100755 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # meincantor-api The API backend for the GCG.MeinCantor school platform built with Rocket.rs in Rust. -It includes a plugin for receiving data from Indiware Mobil and a Keycloak authentication extension. +It includes a plugin for receiving data from Indiware Mobil and an OpenID Connect authentication extension. See the repository of the [main application](https://git.cantorgymnasium.de/cantortechnik/meincantor-app) for additional information. @@ -22,19 +22,15 @@ cargo build --release version: "3.1" services: api: - image: lxdb/meincantor-api + image: registry.cantorgymnasium.de/cantortechnik/meincantor-api restart: always ports: - 8000:8000 environment: - IW_TIMETABLE_URL: https://stundenplan24.de/EXAMPLE_SCHOOL/mobil/mobdaten + IW_TIMETABLE: https://stundenplan24.de/EXAMPLE_SCHOOL/mobil/mobdaten IW_TIMETABLE_USER: EXAMPLE_USER IW_TIMETABLE_PASSWORD: EXAMPLE_PASSWORD - JWT_SECRET: EXAMPLE_SECRET - JWT_ISSUER: Georg-Cantor-Gymnasium Halle(Saale) - KC_OPENID_TOKEN_ENDPOINT: https://example.keycloak.com/auth/realms/EXAMPLE_REALM/protocol/openid-connect/token - KC_OPENID_USERINFO_ENDPOINT: https://example.keycloak.com/auth/realms/EXAMPLE_REALM/protocol/openid-connect/userinfo - KC_CLIENT_ID: EXAMPLE_CLIENT + OIDC_USERINFO: https://keycloak.example.com/auth/realms/EXAMPLE_REALM/protocol/openid-connect/userinfo volumes: - ./static:/app/static ``` diff --git a/docker-compose.yml b/docker-compose.yml index 6de64d4..b9cbab0 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,14 @@ version: "3.1" services: api: - image: lxdb/meincantor-api + image: registry.cantorgymnasium.de/cantortechnik/meincantor-api restart: always ports: - 8000:8000 environment: - IW_TIMETABLE_URL: https://stundenplan24.de/EXAMPLE_SCHOOL/mobil/mobdaten + IW_TIMETABLE: https://stundenplan24.de/EXAMPLE_SCHOOL/mobil/mobdaten IW_TIMETABLE_USER: EXAMPLE_USER IW_TIMETABLE_PASSWORD: EXAMPLE_PASSWORD - JWT_SECRET: EXAMPLE_SECRET - JWT_ISSUER: Georg-Cantor-Gymnasium Halle(Saale) - KC_OPENID_TOKEN_ENDPOINT: https://example.keycloak.com/auth/realms/EXAMPLE_REALM/protocol/openid-connect/token - KC_OPENID_USERINFO_ENDPOINT: https://example.keycloak.com/auth/realms/EXAMPLE_REALM/protocol/openid-connect/userinfo - KC_CLIENT_ID: EXAMPLE_CLIENT + OIDC_USERINFO: https://keycloak.example.com/auth/realms/EXAMPLE_REALM/protocol/openid-connect/userinfo volumes: - ./static:/app/static diff --git a/src/config.rs b/src/config.rs index 7967061..3672dca 100755 --- a/src/config.rs +++ b/src/config.rs @@ -15,17 +15,9 @@ // along with this program. If not, see . // Timetable source -pub static IW_TIMETABLE_URL: &str = "https://stundenplan24.de/EXAMPLE_SCHOOL/mobil/mobdaten"; +pub static IW_TIMETABLE: &str = "https://stundenplan24.de/EXAMPLE_SCHOOL/mobil/mobdaten"; pub static IW_TIMETABLE_USER: &str = "EXAMPLE_USER"; pub static IW_TIMETABLE_PASSWORD: &str = "EXAMPLE_PASSWORD"; -// JWT -pub static JWT_SECRET: &str = "EXAMPLE_SECRET"; -pub static JWT_ISSUER: &str = "Georg-Cantor-Gymnasium Halle(Saale)"; - -// Keycloak -pub static KC_OPENID_TOKEN_ENDPOINT: &str = - "https://example.keycloak.com/auth/realms/EXAMPLE_REALM/protocol/openid-connect/token"; -pub static KC_OPENID_USERINFO_ENDPOINT: &str = +pub static OIDC_USERINFO: &str = "https://example.keycloak.com/auth/realms/EXAMPLE_REALM/protocol/openid-connect/userinfo"; -pub static KC_CLIENT_ID: &str = "EXAMPLE_CLIENT"; diff --git a/src/indiware_connector.rs b/src/indiware_connector.rs index 902d53f..52f091b 100755 --- a/src/indiware_connector.rs +++ b/src/indiware_connector.rs @@ -43,18 +43,17 @@ pub struct Lesson { } async fn get_timetable_xml(url: &str) -> serde_json::value::Value { - let client = reqwest::Client::new(); - let resp = client + let resp = reqwest::Client::new() .get(format!( "{}/{}", - env::var("IW_TIMETABLE_URL").unwrap_or(config::IW_TIMETABLE_URL.to_string()), + env::var("IW_TIMETABLE").unwrap_or_else(|_| config::IW_TIMETABLE.to_string()), url )) .basic_auth( - env::var("IW_TIMETABLE_USER").unwrap_or(config::IW_TIMETABLE_USER.to_string()), + env::var("IW_TIMETABLE_USER").unwrap_or_else(|_| config::IW_TIMETABLE_USER.to_string()), Some( env::var("IW_TIMETABLE_PASSWORD") - .unwrap_or(config::IW_TIMETABLE_PASSWORD.to_string()), + .unwrap_or_else(|_| config::IW_TIMETABLE_PASSWORD.to_string()), ), ) .send() @@ -67,8 +66,8 @@ async fn get_timetable_xml(url: &str) -> serde_json::value::Value { } async fn get_timetable_xml_data(url: &str) -> Vec { - let xml = get_timetable_xml(url).await; - let classes = xml + get_timetable_xml(url) + .await .as_object() .unwrap() .get("VpMobil") @@ -78,14 +77,15 @@ async fn get_timetable_xml_data(url: &str) -> Vec { .get("Kl") .unwrap() .as_array() - .unwrap(); - classes.to_owned() + .unwrap() + .to_owned() } pub async fn get_timetable(url: String) -> Vec { let xml = get_timetable_xml(&url).await; let classes = get_timetable_xml_data(&url).await; let mut timetable: Vec = Vec::new(); + //dbg!(&classes); for i in classes.iter() { let mut courses: Vec = Vec::new(); let nothing = json!([""]); @@ -104,11 +104,11 @@ pub async fn get_timetable(url: String) -> Vec { } else if std.is_object() { plan.push(std.clone()) } - for i in &plan { - if i.as_object() != None { - courses.push(i.to_owned()); + for x in &plan { + if x.as_object() != None { + courses.push(x.to_owned()); } else { - dbg!("Failed: {:?}", &i); + dbg!("Failed to decode plan: {:?}", &i); } } let empty_list = serde_json::Value::Array(vec![]); @@ -132,36 +132,16 @@ pub async fn get_timetable(url: String) -> Vec { } else if info_value.is_string() { info.push_str(info_value.as_str().unwrap_or("")); } - let response = TimetableData { - count: plan.len(), - courses, - info, - }; + let header = xml + .as_object() + .unwrap() + .get("VpMobil") + .unwrap() + .get("Kopf") + .unwrap(); let timetable_element = Timetable { - date: String::from( - xml.as_object() - .unwrap() - .get("VpMobil") - .unwrap() - .get("Kopf") - .unwrap() - .get("DatumPlan") - .unwrap() - .as_str() - .unwrap(), - ), - updated: String::from( - xml.as_object() - .unwrap() - .get("VpMobil") - .unwrap() - .get("Kopf") - .unwrap() - .get("zeitstempel") - .unwrap() - .as_str() - .unwrap(), - ), + date: String::from(header.get("DatumPlan").unwrap().as_str().unwrap()), + updated: String::from(header.get("zeitstempel").unwrap().as_str().unwrap()), class: String::from( i.as_object() .unwrap() @@ -170,167 +150,87 @@ pub async fn get_timetable(url: String) -> Vec { .as_str() .unwrap(), ), - timetable_data: json!(response), + timetable_data: json!(TimetableData { + count: plan.len(), + courses, + info, + }), }; timetable.push(timetable_element) } let normal_classes: Vec = timetable - .to_vec() - .into_iter() + .iter() + .cloned() .filter(|e| !e.class.contains("11") && !e.class.contains("12")) .collect(); - let eleven_classes: Vec = timetable - .to_vec() - .into_iter() - .filter(|e| e.class.contains("11")) - .collect(); - let twelve_classes: Vec = timetable - .to_vec() - .into_iter() - .filter(|e| e.class.contains("12")) - .collect(); - let eleven_timetable_data = { - let mut courses: Vec = Vec::new(); - let mut info: String = String::new(); - for i in eleven_classes { - courses.extend( - i.timetable_data - .as_object() - .unwrap() - .get("courses") - .unwrap() - .as_array() - .unwrap() - .to_owned(), - ); - info = i - .timetable_data - .as_object() - .unwrap() - .get("info") - .unwrap() - .as_str() - .unwrap() - .to_owned(); - } - courses.sort_by(|a, b| { - let n1 = a.as_object().unwrap().get("St").unwrap().as_i64().unwrap(); - let sb1 = a.as_object().unwrap().get("Fa").unwrap().as_str().unwrap(); - let n2 = b.as_object().unwrap().get("St").unwrap().as_i64().unwrap(); - let sb2 = b.as_object().unwrap().get("Fa").unwrap().as_str().unwrap(); - if n1 == n2 { - sb1.cmp(&sb2) - } else { - n1.cmp(&n2) - } - }); - TimetableData { - count: courses.len(), - courses, - info, - } - }; - let eleven = Timetable { - date: String::from( - xml.as_object() - .unwrap() - .get("VpMobil") - .unwrap() - .get("Kopf") - .unwrap() - .get("DatumPlan") - .unwrap() - .as_str() - .unwrap(), - ), - updated: String::from( - xml.as_object() - .unwrap() - .get("VpMobil") - .unwrap() - .get("Kopf") - .unwrap() - .get("zeitstempel") - .unwrap() - .as_str() - .unwrap(), - ), - class: String::from("11"), - timetable_data: json!(eleven_timetable_data), - }; - let twelve_timetable_data = { - let mut courses: Vec = Vec::new(); - let mut info: String = String::new(); - for i in twelve_classes { - courses.extend( - i.timetable_data - .as_object() - .unwrap() - .get("courses") - .unwrap() - .as_array() - .unwrap() - .to_owned(), - ); - info = i - .timetable_data - .as_object() - .unwrap() - .get("info") - .unwrap() - .as_str() - .unwrap() - .to_owned(); - } - courses.sort_by(|a, b| { - let n1 = a.as_object().unwrap().get("St").unwrap().as_i64().unwrap(); - let sb1 = a.as_object().unwrap().get("Fa").unwrap().as_str().unwrap(); - let n2 = b.as_object().unwrap().get("St").unwrap().as_i64().unwrap(); - let sb2 = b.as_object().unwrap().get("Fa").unwrap().as_str().unwrap(); - if n1 == n2 { - sb1.cmp(&sb2) - } else { - n1.cmp(&n2) - } - }); - TimetableData { - count: courses.len(), - courses, - info, - } - }; - let twelve = Timetable { - date: String::from( - xml.as_object() - .unwrap() - .get("VpMobil") - .unwrap() - .get("Kopf") - .unwrap() - .get("DatumPlan") - .unwrap() - .as_str() - .unwrap(), - ), - updated: String::from( - xml.as_object() - .unwrap() - .get("VpMobil") - .unwrap() - .get("Kopf") - .unwrap() - .get("zeitstempel") - .unwrap() - .as_str() - .unwrap(), - ), - class: String::from("12"), - timetable_data: json!(twelve_timetable_data), - }; let mut timetable_refactored: Vec = Vec::new(); timetable_refactored.extend(normal_classes); - timetable_refactored.push(eleven); - timetable_refactored.push(twelve); + for year in ["11", "12"] { + let class: Vec = timetable + .iter() + .cloned() + .filter(|e| e.class.contains(year)) + .collect(); + let timetable_data = { + let mut courses: Vec = Vec::new(); + let mut info: String = String::new(); + for i in class { + let td_obj = i.timetable_data.as_object().unwrap(); + courses.extend( + td_obj + .get("courses") + .unwrap() + .as_array() + .unwrap() + .to_owned(), + ); + info = td_obj.get("info").unwrap().as_str().unwrap().to_owned(); + } + courses.sort_by(|a, b| { + fn st_nr(obj: &serde_json::Value) -> i64 { + obj.as_object() + .unwrap() + .get("St") + .unwrap_or(&json!([""])) + .as_i64() + .unwrap_or_default() + } + fn fa_abc(obj: &serde_json::Value) -> String { + obj.as_object() + .unwrap() + .get("Fa") + .unwrap_or(&json!([""])) + .as_str() + .unwrap_or_default() + .to_owned() + } + if st_nr(a) == st_nr(b) { + fa_abc(a).cmp(&fa_abc(b)) + } else { + st_nr(a).cmp(&st_nr(b)) + } + }); + TimetableData { + count: courses.len(), + courses, + info, + } + }; + let header = xml + .as_object() + .unwrap() + .get("VpMobil") + .unwrap() + .get("Kopf") + .unwrap(); + let timetable = Timetable { + date: String::from(header.get("DatumPlan").unwrap().as_str().unwrap()), + updated: String::from(header.get("zeitstempel").unwrap().as_str().unwrap()), + class: year.to_string(), + timetable_data: json!(timetable_data), + }; + timetable_refactored.push(timetable); + } timetable_refactored } @@ -365,15 +265,16 @@ pub async fn get_class_timetable(class: String, url: String) -> TimetableData { info, }; for i in classes.iter() { - if i.as_object() + let current_class = i + .as_object() .unwrap() .get("Kurz") .unwrap() .as_str() .unwrap() - .replace("/", "_") - == class - { + .replace('/', "_"); + let contains_class = current_class.contains(&class); + if class == current_class { let nothing = json!([""]); let std = i .as_object() @@ -399,16 +300,7 @@ pub async fn get_class_timetable(class: String, url: String) -> TimetableData { } } break; - } else if class == String::from("11") - && i.as_object() - .unwrap() - .get("Kurz") - .unwrap() - .as_str() - .unwrap() - .replace("/", "_") - .contains(&class) - { + } else if (class == *"11" || class == *"12") && contains_class { let nothing = json!([""]); let std = i .as_object() @@ -433,48 +325,28 @@ pub async fn get_class_timetable(class: String, url: String) -> TimetableData { } } response.courses.sort_by(|a, b| { - let n1 = a.as_object().unwrap().get("St").unwrap().as_i64().unwrap(); - let n2 = b.as_object().unwrap().get("St").unwrap().as_i64().unwrap(); - n1.cmp(&n2) - }); - response.count = response.courses.len(); - } else if class == String::from("12") - && i.as_object() - .unwrap() - .get("Kurz") - .unwrap() - .as_str() - .unwrap() - .replace("/", "_") - .contains(&class) - { - let nothing = json!([""]); - let std = i - .as_object() - .unwrap() - .get("Pl") - .unwrap() - .as_object() - .unwrap() - .get("Std") - .unwrap_or(¬hing); - let mut plan = vec![]; - if std.is_array() { - plan.extend(std.as_array().unwrap().iter().cloned()) - } else if std.is_object() { - plan.push(std.clone()) - } - for i in plan { - if i.as_object() != None { - response.courses.push(i.to_owned()); - } else { - dbg!("Failed: {:?}", &i); + fn st_nr(obj: &serde_json::Value) -> i64 { + obj.as_object() + .unwrap() + .get("St") + .unwrap_or(&json!([""])) + .as_i64() + .unwrap_or_default() + } + fn fa_abc(obj: &serde_json::Value) -> String { + obj.as_object() + .unwrap() + .get("Fa") + .unwrap_or(&json!([""])) + .as_str() + .unwrap_or_default() + .to_owned() + } + if st_nr(a) == st_nr(b) { + fa_abc(a).cmp(&fa_abc(b)) + } else { + st_nr(a).cmp(&st_nr(b)) } - } - response.courses.sort_by(|a, b| { - let n1 = a.as_object().unwrap().get("St").unwrap().as_i64().unwrap(); - let n2 = b.as_object().unwrap().get("St").unwrap().as_i64().unwrap(); - n1.cmp(&n2) }); response.count = response.courses.len(); } @@ -486,41 +358,20 @@ pub async fn get_classes() -> Vec { let classes = get_timetable_xml_data(&String::from("Klassen.xml")).await; let mut class_list: Vec = Vec::new(); for i in classes.iter() { - if String::from( - i.as_object() - .unwrap() - .get("Kurz") - .unwrap() - .as_str() - .unwrap(), - ) - .contains("11") - { - if !class_list.contains(&"11".to_string()) { - class_list.push("11".to_string()); - } - } else if String::from( - i.as_object() - .unwrap() - .get("Kurz") - .unwrap() - .as_str() - .unwrap(), - ) - .contains("12") - { - if !class_list.contains(&"12".to_string()) { - class_list.push("12".to_string()); - } - } else { - class_list.push(String::from( - i.as_object() - .unwrap() - .get("Kurz") - .unwrap() - .as_str() - .unwrap(), - )); + let current_class = i + .as_object() + .unwrap() + .get("Kurz") + .unwrap() + .as_str() + .unwrap() + .to_string(); + if current_class.contains("11") && !class_list.contains(&"11".to_string()) { + class_list.push("11".to_string()); + } else if current_class.contains("12") && !class_list.contains(&"12".to_string()) { + class_list.push("12".to_string()); + } else if !current_class.contains("11") && !current_class.contains("12") { + class_list.push(current_class); } } class_list @@ -531,85 +382,16 @@ pub async fn get_class_lessons(class: String) -> Vec { let mut lesson_list: Vec = Vec::new(); for i in classes.iter() { let empty_list = serde_json::Value::Array(Vec::new()); - if i.as_object() + let current_class = i + .as_object() .unwrap() .get("Kurz") .unwrap() .as_str() .unwrap() - .replace("/", "_") - == class - { - let class_lessons = i - .as_object() - .unwrap() - .get("Unterricht") - .unwrap() - .as_object() - .unwrap() - .get("Ue") - .unwrap_or(&empty_list) - .as_array() - .unwrap(); - for lesson in class_lessons.iter() { - let lesson = lesson - .as_object() - .unwrap() - .get("UeNr") - .unwrap() - .as_object() - .unwrap(); - lesson_list.push(Lesson { - subject: lesson.get("@UeFa").unwrap().as_str().unwrap().to_string(), - teacher: lesson.get("@UeLe").unwrap().as_str().unwrap().to_string(), - id: lesson.get("#text").unwrap().as_i64().unwrap(), - }) - } - } else if class == String::from("11") - && i.as_object() - .unwrap() - .get("Kurz") - .unwrap() - .as_str() - .unwrap() - .replace("/", "_") - .contains(&class) - { - let class_lessons = i - .as_object() - .unwrap() - .get("Unterricht") - .unwrap() - .as_object() - .unwrap() - .get("Ue") - .unwrap_or(&empty_list) - .as_array() - .unwrap(); - for lesson in class_lessons.iter() { - let lesson = lesson - .as_object() - .unwrap() - .get("UeNr") - .unwrap() - .as_object() - .unwrap(); - lesson_list.push(Lesson { - subject: lesson.get("@UeFa").unwrap().as_str().unwrap().to_string(), - teacher: lesson.get("@UeLe").unwrap().as_str().unwrap().to_string(), - id: lesson.get("#text").unwrap().as_i64().unwrap(), - }) - } - } else if class == String::from("12") - && i.as_object() - .unwrap() - .get("Kurz") - .unwrap() - .as_str() - .unwrap() - .replace("/", "_") - .contains(&class) - { + .replace('/', "_"); + let contains_class = current_class.contains(&class); + if (class == current_class) || ((class == *"11" || class == *"12") && contains_class) { let class_lessons = i .as_object() .unwrap() diff --git a/src/keycloak_connector.rs b/src/keycloak_connector.rs deleted file mode 100755 index 1bef467..0000000 --- a/src/keycloak_connector.rs +++ /dev/null @@ -1,282 +0,0 @@ -// GCG.MeinCantor.API - The server-part of GCG.MeinCantor - The school application for the Georg-Cantor-Gymnasium -// Copyright (C) 2021-2022 Denys Konovalov - -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, or -// any later version. - -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. - -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -extern crate reqwest; - -use crate::config; -use crate::{Claims, Credentials, Roles, Token, TokenStatus}; - -use jsonwebtoken::{encode, EncodingKey, Header}; -use rocket::{response::status, serde::json::Json}; -use serde_derive::{Deserialize, Serialize}; -use serde_json::json; -use std::env; -use std::error::Error; -use std::fmt::Display; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use time::{macros::format_description, OffsetDateTime}; - -#[derive(Debug, Deserialize, Serialize)] -pub struct KeycloakAdminToken { - pub access_token: String, - pub expires_in: usize, - #[serde(rename = "not-before-policy")] - pub not_before_policy: Option, - pub refresh_expires_in: Option, - pub refresh_token: Option, - pub scope: String, - pub session_state: Option, - pub token_type: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct KeycloakUser { - sub: String, - email_verified: bool, - pub roles: Vec, - name: String, - pub blacklist: Option>, - pub groups: Vec, - pub whitelist: Option>, - pub preferred_username: String, - given_name: String, - family_name: String, - email: String, -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct KeycloakHttpError { - pub error: Option, - #[serde(rename = "errorMessage")] - pub error_message: Option, -} - -#[derive(Debug)] -pub enum KeycloakError { - ReqwestFailure(reqwest::Error), - HttpFailure { - status: u16, - body: Option, - text: String, - }, -} - -impl From for KeycloakError { - fn from(value: reqwest::Error) -> Self { - KeycloakError::ReqwestFailure(value) - } -} - -impl Error for KeycloakError { - fn description(&self) -> &str { - "keycloak error" - } - - fn cause(&self) -> Option<&dyn Error> { - None - } -} - -impl Display for KeycloakError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "keycloak error") - } -} - -async fn error_check(response: reqwest::Response) -> Result { - if !response.status().is_success() { - let status = response.status().into(); - let text = response.text().await?; - return Err(KeycloakError::HttpFailure { - status, - body: serde_json::from_str(&text).ok(), - text, - }); - } - Ok(response) -} - -pub async fn get_keycloak_token( - user: String, - password: String, - otp: String, -) -> Result { - let client = reqwest::Client::new(); - let params = [ - ("username", user), - ("password", password), - ("totp", otp), - ( - "client_id", - env::var("KC_CLIENT_ID").unwrap_or(config::KC_CLIENT_ID.to_string()), - ), - ("grant_type", String::from("password")), - ]; - let resp = client - .post( - env::var("KC_OPENID_TOKEN_ENDPOINT") - .unwrap_or(config::KC_OPENID_TOKEN_ENDPOINT.to_string()), - ) - .form(¶ms) - .send() - .await?; - Ok(error_check(resp).await?.json().await?) -} - -pub async fn get_keycloak_userinfo(token: String) -> Result> { - let client = reqwest::Client::new(); - let resp = client - .get( - env::var("KC_OPENID_USERINFO_ENDPOINT") - .unwrap_or(config::KC_OPENID_USERINFO_ENDPOINT.to_string()), - ) - .header("Authorization", format!("Bearer {}", token)) - .send() - .await? - .json::() - .await?; - Ok(resp) -} - -pub async fn get_userinfo( - credentials: Json, -) -> Result, status::Unauthorized> { - let credentials = credentials.into_inner(); - let keycloak_resp = get_keycloak_token( - credentials.user.clone(), - credentials.password.clone(), - credentials.otp.clone(), - ) - .await; - let token = match keycloak_resp { - Ok(token) => Token { - outcome: (TokenStatus::Success, String::new()), - token: token.access_token, - }, - Err(e) => { - let outcome = match e { - KeycloakError::ReqwestFailure(f) => (TokenStatus::HttpError, f.to_string()), - KeycloakError::HttpFailure { - status: _s, - body: _b, - text: t, - } => ( - TokenStatus::KeycloakError, - String::from( - serde_json::from_str(&t[..]).unwrap_or_else( - |_| json![{"error_description": "No error description"}], - )["error_description"] - .as_str() - .unwrap(), - ), - ), - }; - Token { - outcome, - token: String::new(), - } - } - }; - let result = match token.outcome.0 { - TokenStatus::Success => Ok(Json( - get_keycloak_userinfo(token.token.clone()).await.unwrap(), - )), - _ => Err(status::Unauthorized::(Some(token.outcome.1))), - }; - - result -} - -pub async fn login( - credentials: Json, -) -> Result, status::Unauthorized> { - let credentials = credentials.into_inner(); - let keycloak_resp = get_keycloak_token( - credentials.user.clone(), - credentials.password.clone(), - credentials.otp.clone(), - ) - .await; - let token = match keycloak_resp { - Ok(token) => Token { - outcome: (TokenStatus::Success, String::new()), - token: token.access_token, - }, - Err(e) => { - let outcome = match e { - KeycloakError::ReqwestFailure(f) => (TokenStatus::HttpError, f.to_string()), - KeycloakError::HttpFailure { - status: _s, - body: _b, - text: t, - } => ( - TokenStatus::KeycloakError, - String::from( - serde_json::from_str(&t[..]).unwrap_or_else( - |_| json![{"error_description": "No error description"}], - )["error_description"] - .as_str() - .unwrap(), - ), - ), - }; - Token { - outcome, - token: String::new(), - } - } - }; - - let result = match token.outcome.0 { - TokenStatus::Success => { - let userinfo = get_keycloak_userinfo(token.token.clone()).await.unwrap(); - let system_time = OffsetDateTime::now_utc(); - let datetime = system_time.format(format_description!( - "[day]/[month]/[year] [hour]:[minute]:[second]" - )); - let my_claims = Claims { - iss: env::var("JWT_ISSUER").unwrap_or(config::JWT_ISSUER.to_string()), - user: userinfo.preferred_username, - roles: userinfo.roles, - groups: userinfo.groups, - blacklist: userinfo.blacklist.unwrap_or_default(), - whitelist: userinfo.whitelist.unwrap_or_default(), - jid: (credentials.devid + "@" + &datetime.unwrap()), - exp: SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs() - + Duration::from_secs(31536000).as_secs(), - }; - println!("{:?}", SystemTime::now()); - let jwt = encode( - &Header::default(), - &my_claims, - &EncodingKey::from_secret( - env::var("JWT_SECRET") - .unwrap_or(config::JWT_SECRET.to_string()) - .as_ref(), - ), - ); - Ok(Json(Token { - outcome: (TokenStatus::Success, String::new()), - token: jwt.unwrap(), - })) - } - _ => Err(status::Unauthorized::(Some(token.outcome.1))), - }; - result -} diff --git a/src/main.rs b/src/main.rs index 981b951..2cc518f 100755 --- a/src/main.rs +++ b/src/main.rs @@ -19,24 +19,74 @@ extern crate rocket; mod config; mod indiware_connector; -mod keycloak_connector; extern crate reqwest; extern crate serde; extern crate serde_json; use indiware_connector as timetable_connector; -use jsonwebtoken::{decode, DecodingKey, Validation}; -use keycloak_connector::KeycloakUser; use rocket::{ fs::{relative, FileServer}, http::Status, request::{FromRequest, Outcome, Request}, - response::status, serde::json::Json, }; use serde_derive::{Deserialize, Serialize}; -use std::env; +use std::{env, error::Error, fmt::Display}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct OpenidConnectUser { + sub: String, + email_verified: bool, + pub roles: Vec, + name: String, + pub blacklist: Option>, + pub groups: Vec, + pub whitelist: Option>, + pub preferred_username: String, + given_name: String, + family_name: String, + email: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct OpenidConnectHttpError { + pub error: Option, + #[serde(rename = "errorMessage")] + pub error_message: Option, +} + +#[derive(Debug)] +pub enum OpenidConnectError { + ReqwestFailure(reqwest::Error), + HttpFailure { + status: u16, + body: Option, + text: String, + }, +} + +impl From for OpenidConnectError { + fn from(value: reqwest::Error) -> Self { + OpenidConnectError::ReqwestFailure(value) + } +} + +impl Error for OpenidConnectError { + fn description(&self) -> &str { + "keycloak error" + } + + fn cause(&self) -> Option<&dyn Error> { + None + } +} + +impl Display for OpenidConnectError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "keycloak error") + } +} #[derive(Debug, Serialize, Deserialize)] pub struct Credentials { @@ -81,35 +131,43 @@ pub struct Token { struct ApiKey<'r>(&'r str); #[derive(Debug)] -enum ApiKeyError { +enum TokenError { Missing, Invalid, } #[rocket::async_trait] impl<'r> FromRequest<'r> for ApiKey<'r> { - type Error = ApiKeyError; - + type Error = TokenError; async fn from_request(req: &'r Request<'_>) -> Outcome { - /// Returns true if `key` is a valid API key string. - fn is_valid(key: &str) -> bool { - let mut validation = Validation::default(); - validation.validate_exp = false; - let token = decode::( - key, - &DecodingKey::from_secret( - env::var("JWT_SECRET") - .unwrap_or(config::JWT_SECRET.to_string()) - .as_ref(), - ), - &validation, - ); - token.is_ok() + async fn request_valid(token: &str) -> Result { + let client = reqwest::Client::new(); + client + .get( + env::var("OIDC_USERINFO").unwrap_or_else(|_| config::OIDC_USERINFO.to_string()), + ) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await } - fn has_permissions(key: &str, uri: &str) -> bool { - let mut validation = Validation::default(); - validation.validate_exp = false; + async fn token_valid( + resp: reqwest::Response, + ) -> Result { + if !resp.status().is_success() { + let status = resp.status().into(); + let text = resp.text().await?; + return Err(OpenidConnectError::HttpFailure { + status, + body: serde_json::from_str(&text).ok(), + text, + }); + } + Ok(resp) + } + + async fn has_permissions(kc_user: reqwest::Response, uri: &str) -> bool { + let user = kc_user.json::().await.unwrap(); let standard_permissions = vec![ String::from("/api/timetable"), String::from("/api/classes"), @@ -117,19 +175,9 @@ impl<'r> FromRequest<'r> for ApiKey<'r> { ]; let student_permissions: Vec = vec![]; let teacher_permissions: Vec = vec![]; - let token = decode::( - key, - &DecodingKey::from_secret( - env::var("JWT_SECRET") - .unwrap_or(config::JWT_SECRET.to_string()) - .as_ref(), - ), - &validation, - ); - let token = token.unwrap(); let mut permissions = Vec::new(); permissions.extend(standard_permissions.iter().cloned()); - for role in token.claims.roles.iter() { + for role in user.roles.iter() { match role { Roles::Admin => permissions.push(String::from("all")), Roles::Student => permissions.extend(student_permissions.iter().cloned()), @@ -138,43 +186,55 @@ impl<'r> FromRequest<'r> for ApiKey<'r> { } permissions.contains(&String::from(uri)) | permissions.contains(&String::from("all")) - | token.claims.whitelist.contains(&String::from(uri)) - && !token.claims.blacklist.contains(&String::from(uri)) + | user + .whitelist + .unwrap_or_default() + .contains(&String::from(uri)) + && !user + .blacklist + .unwrap_or_default() + .contains(&String::from(uri)) } match req.headers().get_one("x-api-key") { - None => Outcome::Failure((Status::Unauthorized, ApiKeyError::Missing)), - Some(key) - if is_valid(key) - && has_permissions(key, req.route().unwrap().uri.base.path().as_str()) => - { - Outcome::Success(ApiKey(key)) + None => Outcome::Failure((Status::Unauthorized, TokenError::Missing)), + Some(token) => { + match request_valid(token).await { + Ok(resp) => match token_valid(resp).await { + Ok(kc_user) => { + if has_permissions( + kc_user, + req.route().unwrap().uri.base.path().as_str(), + ) + .await + { + Outcome::Success(ApiKey(token)) + } else { + Outcome::Failure((Status::Unauthorized, TokenError::Invalid)) + } + } + Err(kc_err) => { + match kc_err { + OpenidConnectError::ReqwestFailure(e) => Outcome::Failure(( + Status::new(e.status().unwrap().as_u16()), + TokenError::Invalid, + )), + OpenidConnectError::HttpFailure { + status, + body: _, + text: _, + } => Outcome::Failure((Status::new(status), TokenError::Invalid)), + } + // + } + }, + Err(_) => Outcome::Failure((Status::BadRequest, TokenError::Invalid)), + } } - Some(key) - if is_valid(key) - && !has_permissions(key, req.route().unwrap().uri.base.path().as_str()) => - { - Outcome::Failure((Status::Unauthorized, ApiKeyError::Invalid)) - } - Some(_) => Outcome::Failure((Status::BadRequest, ApiKeyError::Invalid)), } } } -#[post("/", data = "")] -async fn get_userinfo( - credentials: Json, -) -> Result, status::Unauthorized> { - keycloak_connector::get_userinfo(credentials).await -} - -#[post("/", data = "")] -async fn login( - credentials: Json, -) -> Result, status::Unauthorized> { - keycloak_connector::login(credentials).await -} - #[get("/latest")] async fn get_latest_timetable(_key: ApiKey<'_>) -> Json> { let timetable = timetable_connector::get_timetable(String::from("Klassen.xml")).await; @@ -230,7 +290,6 @@ async fn get_class_lessons( fn rocket() -> _ { rocket::build() .mount("/", FileServer::from(relative!("static"))) - .mount("/login", routes![login]) .mount( "/api/timetable", routes![ @@ -242,5 +301,4 @@ fn rocket() -> _ { ) .mount("/api/classes", routes![get_classes]) .mount("/api/lessons", routes![get_class_lessons]) - .mount("/api/userinfo", routes![get_userinfo]) }