Change authentication/authorization flow, code cleanup, severa fixes

This commit is contained in:
Denys Konovalov 2022-08-28 17:13:43 +02:00
parent 5db7f250e3
commit 31486a9d5e
7 changed files with 278 additions and 739 deletions

@ -1,19 +1,16 @@
[package] [package]
name = "api" name = "api"
version = "1.0.0" version = "2.0.0-alpha.1"
edition = "2018" edition = "2018"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = ["Denys Konovalov <denys.konovalov@protonmail.com>"] authors = ["Denys Konovalov <kontakt@denyskon.de>"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
rocket = { version = "0.5.0-rc.1", features = ["json"] } rocket = { version = "0.5.0-rc.2", features = ["json"] }
serde = "1.0" serde = "1.0"
reqwest = { version="0.11", features = ["json"] } reqwest = { version="0.11", features = ["json"] }
quickxml_to_serde = "0.5" quickxml_to_serde = "0.5"
serde_json = "1.0" serde_json = "1.0"
serde_derive = "1.0" serde_derive = "1.0"
jsonwebtoken = "8.1"
time = "0.3"
chrono = "0.4"

@ -1,7 +1,7 @@
# meincantor-api # meincantor-api
The API backend for the GCG.MeinCantor school platform built with Rocket.rs in Rust. 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. 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" version: "3.1"
services: services:
api: api:
image: lxdb/meincantor-api image: registry.cantorgymnasium.de/cantortechnik/meincantor-api
restart: always restart: always
ports: ports:
- 8000:8000 - 8000:8000
environment: 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_USER: EXAMPLE_USER
IW_TIMETABLE_PASSWORD: EXAMPLE_PASSWORD IW_TIMETABLE_PASSWORD: EXAMPLE_PASSWORD
JWT_SECRET: EXAMPLE_SECRET OIDC_USERINFO: https://keycloak.example.com/auth/realms/EXAMPLE_REALM/protocol/openid-connect/userinfo
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
volumes: volumes:
- ./static:/app/static - ./static:/app/static
``` ```

@ -1,18 +1,14 @@
version: "3.1" version: "3.1"
services: services:
api: api:
image: lxdb/meincantor-api image: registry.cantorgymnasium.de/cantortechnik/meincantor-api
restart: always restart: always
ports: ports:
- 8000:8000 - 8000:8000
environment: 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_USER: EXAMPLE_USER
IW_TIMETABLE_PASSWORD: EXAMPLE_PASSWORD IW_TIMETABLE_PASSWORD: EXAMPLE_PASSWORD
JWT_SECRET: EXAMPLE_SECRET OIDC_USERINFO: https://keycloak.example.com/auth/realms/EXAMPLE_REALM/protocol/openid-connect/userinfo
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
volumes: volumes:
- ./static:/app/static - ./static:/app/static

@ -15,17 +15,9 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
// Timetable source // 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_USER: &str = "EXAMPLE_USER";
pub static IW_TIMETABLE_PASSWORD: &str = "EXAMPLE_PASSWORD"; pub static IW_TIMETABLE_PASSWORD: &str = "EXAMPLE_PASSWORD";
// JWT pub static OIDC_USERINFO: &str =
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 =
"https://example.keycloak.com/auth/realms/EXAMPLE_REALM/protocol/openid-connect/userinfo"; "https://example.keycloak.com/auth/realms/EXAMPLE_REALM/protocol/openid-connect/userinfo";
pub static KC_CLIENT_ID: &str = "EXAMPLE_CLIENT";

@ -43,18 +43,17 @@ pub struct Lesson {
} }
async fn get_timetable_xml(url: &str) -> serde_json::value::Value { async fn get_timetable_xml(url: &str) -> serde_json::value::Value {
let client = reqwest::Client::new(); let resp = reqwest::Client::new()
let resp = client
.get(format!( .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 url
)) ))
.basic_auth( .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( Some(
env::var("IW_TIMETABLE_PASSWORD") env::var("IW_TIMETABLE_PASSWORD")
.unwrap_or(config::IW_TIMETABLE_PASSWORD.to_string()), .unwrap_or_else(|_| config::IW_TIMETABLE_PASSWORD.to_string()),
), ),
) )
.send() .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<serde_json::value::Value> { async fn get_timetable_xml_data(url: &str) -> Vec<serde_json::value::Value> {
let xml = get_timetable_xml(url).await; get_timetable_xml(url)
let classes = xml .await
.as_object() .as_object()
.unwrap() .unwrap()
.get("VpMobil") .get("VpMobil")
@ -78,14 +77,15 @@ async fn get_timetable_xml_data(url: &str) -> Vec<serde_json::value::Value> {
.get("Kl") .get("Kl")
.unwrap() .unwrap()
.as_array() .as_array()
.unwrap(); .unwrap()
classes.to_owned() .to_owned()
} }
pub async fn get_timetable(url: String) -> Vec<Timetable> { pub async fn get_timetable(url: String) -> Vec<Timetable> {
let xml = get_timetable_xml(&url).await; let xml = get_timetable_xml(&url).await;
let classes = get_timetable_xml_data(&url).await; let classes = get_timetable_xml_data(&url).await;
let mut timetable: Vec<Timetable> = Vec::new(); let mut timetable: Vec<Timetable> = Vec::new();
//dbg!(&classes);
for i in classes.iter() { for i in classes.iter() {
let mut courses: Vec<rocket::serde::json::Value> = Vec::new(); let mut courses: Vec<rocket::serde::json::Value> = Vec::new();
let nothing = json!([""]); let nothing = json!([""]);
@ -104,11 +104,11 @@ pub async fn get_timetable(url: String) -> Vec<Timetable> {
} else if std.is_object() { } else if std.is_object() {
plan.push(std.clone()) plan.push(std.clone())
} }
for i in &plan { for x in &plan {
if i.as_object() != None { if x.as_object() != None {
courses.push(i.to_owned()); courses.push(x.to_owned());
} else { } else {
dbg!("Failed: {:?}", &i); dbg!("Failed to decode plan: {:?}", &i);
} }
} }
let empty_list = serde_json::Value::Array(vec![]); let empty_list = serde_json::Value::Array(vec![]);
@ -132,36 +132,16 @@ pub async fn get_timetable(url: String) -> Vec<Timetable> {
} else if info_value.is_string() { } else if info_value.is_string() {
info.push_str(info_value.as_str().unwrap_or("")); info.push_str(info_value.as_str().unwrap_or(""));
} }
let response = TimetableData { let header = xml
count: plan.len(), .as_object()
courses, .unwrap()
info, .get("VpMobil")
}; .unwrap()
.get("Kopf")
.unwrap();
let timetable_element = Timetable { let timetable_element = Timetable {
date: String::from( date: String::from(header.get("DatumPlan").unwrap().as_str().unwrap()),
xml.as_object() updated: String::from(header.get("zeitstempel").unwrap().as_str().unwrap()),
.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( class: String::from(
i.as_object() i.as_object()
.unwrap() .unwrap()
@ -170,167 +150,87 @@ pub async fn get_timetable(url: String) -> Vec<Timetable> {
.as_str() .as_str()
.unwrap(), .unwrap(),
), ),
timetable_data: json!(response), timetable_data: json!(TimetableData {
count: plan.len(),
courses,
info,
}),
}; };
timetable.push(timetable_element) timetable.push(timetable_element)
} }
let normal_classes: Vec<Timetable> = timetable let normal_classes: Vec<Timetable> = timetable
.to_vec() .iter()
.into_iter() .cloned()
.filter(|e| !e.class.contains("11") && !e.class.contains("12")) .filter(|e| !e.class.contains("11") && !e.class.contains("12"))
.collect(); .collect();
let eleven_classes: Vec<Timetable> = timetable
.to_vec()
.into_iter()
.filter(|e| e.class.contains("11"))
.collect();
let twelve_classes: Vec<Timetable> = timetable
.to_vec()
.into_iter()
.filter(|e| e.class.contains("12"))
.collect();
let eleven_timetable_data = {
let mut courses: Vec<serde_json::Value> = 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<serde_json::Value> = 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<Timetable> = Vec::new(); let mut timetable_refactored: Vec<Timetable> = Vec::new();
timetable_refactored.extend(normal_classes); timetable_refactored.extend(normal_classes);
timetable_refactored.push(eleven); for year in ["11", "12"] {
timetable_refactored.push(twelve); let class: Vec<Timetable> = timetable
.iter()
.cloned()
.filter(|e| e.class.contains(year))
.collect();
let timetable_data = {
let mut courses: Vec<serde_json::Value> = 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 timetable_refactored
} }
@ -365,15 +265,16 @@ pub async fn get_class_timetable(class: String, url: String) -> TimetableData {
info, info,
}; };
for i in classes.iter() { for i in classes.iter() {
if i.as_object() let current_class = i
.as_object()
.unwrap() .unwrap()
.get("Kurz") .get("Kurz")
.unwrap() .unwrap()
.as_str() .as_str()
.unwrap() .unwrap()
.replace("/", "_") .replace('/', "_");
== class let contains_class = current_class.contains(&class);
{ if class == current_class {
let nothing = json!([""]); let nothing = json!([""]);
let std = i let std = i
.as_object() .as_object()
@ -399,16 +300,7 @@ pub async fn get_class_timetable(class: String, url: String) -> TimetableData {
} }
} }
break; break;
} else if class == String::from("11") } else if (class == *"11" || class == *"12") && contains_class {
&& i.as_object()
.unwrap()
.get("Kurz")
.unwrap()
.as_str()
.unwrap()
.replace("/", "_")
.contains(&class)
{
let nothing = json!([""]); let nothing = json!([""]);
let std = i let std = i
.as_object() .as_object()
@ -433,48 +325,28 @@ pub async fn get_class_timetable(class: String, url: String) -> TimetableData {
} }
} }
response.courses.sort_by(|a, b| { response.courses.sort_by(|a, b| {
let n1 = a.as_object().unwrap().get("St").unwrap().as_i64().unwrap(); fn st_nr(obj: &serde_json::Value) -> i64 {
let n2 = b.as_object().unwrap().get("St").unwrap().as_i64().unwrap(); obj.as_object()
n1.cmp(&n2)
});
response.count = response.courses.len();
} else if class == String::from("12")
&& i.as_object()
.unwrap() .unwrap()
.get("Kurz") .get("St")
.unwrap_or(&json!([""]))
.as_i64()
.unwrap_or_default()
}
fn fa_abc(obj: &serde_json::Value) -> String {
obj.as_object()
.unwrap() .unwrap()
.get("Fa")
.unwrap_or(&json!([""]))
.as_str() .as_str()
.unwrap() .unwrap_or_default()
.replace("/", "_") .to_owned()
.contains(&class)
{
let nothing = json!([""]);
let std = i
.as_object()
.unwrap()
.get("Pl")
.unwrap()
.as_object()
.unwrap()
.get("Std")
.unwrap_or(&nothing);
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 st_nr(a) == st_nr(b) {
if i.as_object() != None { fa_abc(a).cmp(&fa_abc(b))
response.courses.push(i.to_owned());
} else { } else {
dbg!("Failed: {:?}", &i); 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(); response.count = response.courses.len();
} }
@ -486,41 +358,20 @@ pub async fn get_classes() -> Vec<String> {
let classes = get_timetable_xml_data(&String::from("Klassen.xml")).await; let classes = get_timetable_xml_data(&String::from("Klassen.xml")).await;
let mut class_list: Vec<String> = Vec::new(); let mut class_list: Vec<String> = Vec::new();
for i in classes.iter() { for i in classes.iter() {
if String::from( let current_class = i
i.as_object() .as_object()
.unwrap() .unwrap()
.get("Kurz") .get("Kurz")
.unwrap() .unwrap()
.as_str() .as_str()
.unwrap(), .unwrap()
) .to_string();
.contains("11") if current_class.contains("11") && !class_list.contains(&"11".to_string()) {
{
if !class_list.contains(&"11".to_string()) {
class_list.push("11".to_string()); class_list.push("11".to_string());
} } else if current_class.contains("12") && !class_list.contains(&"12".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()); class_list.push("12".to_string());
} } else if !current_class.contains("11") && !current_class.contains("12") {
} else { class_list.push(current_class);
class_list.push(String::from(
i.as_object()
.unwrap()
.get("Kurz")
.unwrap()
.as_str()
.unwrap(),
));
} }
} }
class_list class_list
@ -531,85 +382,16 @@ pub async fn get_class_lessons(class: String) -> Vec<Lesson> {
let mut lesson_list: Vec<Lesson> = Vec::new(); let mut lesson_list: Vec<Lesson> = Vec::new();
for i in classes.iter() { for i in classes.iter() {
let empty_list = serde_json::Value::Array(Vec::new()); let empty_list = serde_json::Value::Array(Vec::new());
if i.as_object() let current_class = i
.as_object()
.unwrap() .unwrap()
.get("Kurz") .get("Kurz")
.unwrap() .unwrap()
.as_str() .as_str()
.unwrap() .unwrap()
.replace("/", "_") .replace('/', "_");
== class let contains_class = current_class.contains(&class);
{ if (class == current_class) || ((class == *"11" || class == *"12") && 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("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)
{
let class_lessons = i let class_lessons = i
.as_object() .as_object()
.unwrap() .unwrap()

@ -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 <https://www.gnu.org/licenses/>.
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<usize>,
pub refresh_expires_in: Option<usize>,
pub refresh_token: Option<String>,
pub scope: String,
pub session_state: Option<String>,
pub token_type: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct KeycloakUser {
sub: String,
email_verified: bool,
pub roles: Vec<Roles>,
name: String,
pub blacklist: Option<Vec<String>>,
pub groups: Vec<String>,
pub whitelist: Option<Vec<String>>,
pub preferred_username: String,
given_name: String,
family_name: String,
email: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct KeycloakHttpError {
pub error: Option<String>,
#[serde(rename = "errorMessage")]
pub error_message: Option<String>,
}
#[derive(Debug)]
pub enum KeycloakError {
ReqwestFailure(reqwest::Error),
HttpFailure {
status: u16,
body: Option<KeycloakHttpError>,
text: String,
},
}
impl From<reqwest::Error> 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<reqwest::Response, KeycloakError> {
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<KeycloakAdminToken, KeycloakError> {
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(&params)
.send()
.await?;
Ok(error_check(resp).await?.json().await?)
}
pub async fn get_keycloak_userinfo(token: String) -> Result<KeycloakUser, Box<dyn Error>> {
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::<KeycloakUser>()
.await?;
Ok(resp)
}
pub async fn get_userinfo(
credentials: Json<Credentials>,
) -> Result<Json<KeycloakUser>, status::Unauthorized<String>> {
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::<String>(Some(token.outcome.1))),
};
result
}
pub async fn login(
credentials: Json<Credentials>,
) -> Result<Json<Token>, status::Unauthorized<String>> {
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::<String>(Some(token.outcome.1))),
};
result
}

@ -19,24 +19,74 @@ extern crate rocket;
mod config; mod config;
mod indiware_connector; mod indiware_connector;
mod keycloak_connector;
extern crate reqwest; extern crate reqwest;
extern crate serde; extern crate serde;
extern crate serde_json; extern crate serde_json;
use indiware_connector as timetable_connector; use indiware_connector as timetable_connector;
use jsonwebtoken::{decode, DecodingKey, Validation};
use keycloak_connector::KeycloakUser;
use rocket::{ use rocket::{
fs::{relative, FileServer}, fs::{relative, FileServer},
http::Status, http::Status,
request::{FromRequest, Outcome, Request}, request::{FromRequest, Outcome, Request},
response::status,
serde::json::Json, serde::json::Json,
}; };
use serde_derive::{Deserialize, Serialize}; 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<Roles>,
name: String,
pub blacklist: Option<Vec<String>>,
pub groups: Vec<String>,
pub whitelist: Option<Vec<String>>,
pub preferred_username: String,
given_name: String,
family_name: String,
email: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct OpenidConnectHttpError {
pub error: Option<String>,
#[serde(rename = "errorMessage")]
pub error_message: Option<String>,
}
#[derive(Debug)]
pub enum OpenidConnectError {
ReqwestFailure(reqwest::Error),
HttpFailure {
status: u16,
body: Option<OpenidConnectHttpError>,
text: String,
},
}
impl From<reqwest::Error> 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)] #[derive(Debug, Serialize, Deserialize)]
pub struct Credentials { pub struct Credentials {
@ -81,35 +131,43 @@ pub struct Token {
struct ApiKey<'r>(&'r str); struct ApiKey<'r>(&'r str);
#[derive(Debug)] #[derive(Debug)]
enum ApiKeyError { enum TokenError {
Missing, Missing,
Invalid, Invalid,
} }
#[rocket::async_trait] #[rocket::async_trait]
impl<'r> FromRequest<'r> for ApiKey<'r> { impl<'r> FromRequest<'r> for ApiKey<'r> {
type Error = ApiKeyError; type Error = TokenError;
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> { async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
/// Returns true if `key` is a valid API key string. async fn request_valid(token: &str) -> Result<reqwest::Response, reqwest::Error> {
fn is_valid(key: &str) -> bool { let client = reqwest::Client::new();
let mut validation = Validation::default(); client
validation.validate_exp = false; .get(
let token = decode::<Claims>( env::var("OIDC_USERINFO").unwrap_or_else(|_| config::OIDC_USERINFO.to_string()),
key, )
&DecodingKey::from_secret( .header("Authorization", format!("Bearer {}", token))
env::var("JWT_SECRET") .send()
.unwrap_or(config::JWT_SECRET.to_string()) .await
.as_ref(),
),
&validation,
);
token.is_ok()
} }
fn has_permissions(key: &str, uri: &str) -> bool { async fn token_valid(
let mut validation = Validation::default(); resp: reqwest::Response,
validation.validate_exp = false; ) -> Result<reqwest::Response, OpenidConnectError> {
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::<OpenidConnectUser>().await.unwrap();
let standard_permissions = vec![ let standard_permissions = vec![
String::from("/api/timetable"), String::from("/api/timetable"),
String::from("/api/classes"), String::from("/api/classes"),
@ -117,19 +175,9 @@ impl<'r> FromRequest<'r> for ApiKey<'r> {
]; ];
let student_permissions: Vec<String> = vec![]; let student_permissions: Vec<String> = vec![];
let teacher_permissions: Vec<String> = vec![]; let teacher_permissions: Vec<String> = vec![];
let token = decode::<Claims>(
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(); let mut permissions = Vec::new();
permissions.extend(standard_permissions.iter().cloned()); permissions.extend(standard_permissions.iter().cloned());
for role in token.claims.roles.iter() { for role in user.roles.iter() {
match role { match role {
Roles::Admin => permissions.push(String::from("all")), Roles::Admin => permissions.push(String::from("all")),
Roles::Student => permissions.extend(student_permissions.iter().cloned()), Roles::Student => permissions.extend(student_permissions.iter().cloned()),
@ -138,41 +186,53 @@ impl<'r> FromRequest<'r> for ApiKey<'r> {
} }
permissions.contains(&String::from(uri)) permissions.contains(&String::from(uri))
| permissions.contains(&String::from("all")) | permissions.contains(&String::from("all"))
| token.claims.whitelist.contains(&String::from(uri)) | user
&& !token.claims.blacklist.contains(&String::from(uri)) .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") { match req.headers().get_one("x-api-key") {
None => Outcome::Failure((Status::Unauthorized, ApiKeyError::Missing)), None => Outcome::Failure((Status::Unauthorized, TokenError::Missing)),
Some(key) Some(token) => {
if is_valid(key) match request_valid(token).await {
&& has_permissions(key, req.route().unwrap().uri.base.path().as_str()) => 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(key)) Outcome::Success(ApiKey(token))
} } else {
Some(key) Outcome::Failure((Status::Unauthorized, TokenError::Invalid))
if is_valid(key) }
&& !has_permissions(key, req.route().unwrap().uri.base.path().as_str()) => }
{ Err(kc_err) => {
Outcome::Failure((Status::Unauthorized, ApiKeyError::Invalid)) match kc_err {
} OpenidConnectError::ReqwestFailure(e) => Outcome::Failure((
Some(_) => Outcome::Failure((Status::BadRequest, ApiKeyError::Invalid)), 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)),
}
}
} }
} }
}
#[post("/", data = "<credentials>")]
async fn get_userinfo(
credentials: Json<Credentials>,
) -> Result<Json<KeycloakUser>, status::Unauthorized<String>> {
keycloak_connector::get_userinfo(credentials).await
}
#[post("/", data = "<credentials>")]
async fn login(
credentials: Json<Credentials>,
) -> Result<Json<Token>, status::Unauthorized<String>> {
keycloak_connector::login(credentials).await
} }
#[get("/latest")] #[get("/latest")]
@ -230,7 +290,6 @@ async fn get_class_lessons(
fn rocket() -> _ { fn rocket() -> _ {
rocket::build() rocket::build()
.mount("/", FileServer::from(relative!("static"))) .mount("/", FileServer::from(relative!("static")))
.mount("/login", routes![login])
.mount( .mount(
"/api/timetable", "/api/timetable",
routes![ routes![
@ -242,5 +301,4 @@ fn rocket() -> _ {
) )
.mount("/api/classes", routes![get_classes]) .mount("/api/classes", routes![get_classes])
.mount("/api/lessons", routes![get_class_lessons]) .mount("/api/lessons", routes![get_class_lessons])
.mount("/api/userinfo", routes![get_userinfo])
} }