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]
name = "api"
version = "1.0.0"
version = "2.0.0-alpha.1"
edition = "2018"
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
[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"

@ -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
```

@ -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

@ -15,17 +15,9 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// 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";

@ -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<serde_json::value::Value> {
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<serde_json::value::Value> {
.get("Kl")
.unwrap()
.as_array()
.unwrap();
classes.to_owned()
.unwrap()
.to_owned()
}
pub async fn get_timetable(url: String) -> Vec<Timetable> {
let xml = get_timetable_xml(&url).await;
let classes = get_timetable_xml_data(&url).await;
let mut timetable: Vec<Timetable> = Vec::new();
//dbg!(&classes);
for i in classes.iter() {
let mut courses: Vec<rocket::serde::json::Value> = Vec::new();
let nothing = json!([""]);
@ -104,11 +104,11 @@ pub async fn get_timetable(url: String) -> Vec<Timetable> {
} 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<Timetable> {
} 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<Timetable> {
.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> = timetable
.to_vec()
.into_iter()
.iter()
.cloned()
.filter(|e| !e.class.contains("11") && !e.class.contains("12"))
.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();
timetable_refactored.extend(normal_classes);
timetable_refactored.push(eleven);
timetable_refactored.push(twelve);
for year in ["11", "12"] {
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
}
@ -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(&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 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<String> {
let classes = get_timetable_xml_data(&String::from("Klassen.xml")).await;
let mut class_list: Vec<String> = 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<Lesson> {
let mut lesson_list: Vec<Lesson> = 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()

@ -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 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<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)]
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<Self, Self::Error> {
/// 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::<Claims>(
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<reqwest::Response, reqwest::Error> {
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<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![
String::from("/api/timetable"),
String::from("/api/classes"),
@ -117,19 +175,9 @@ impl<'r> FromRequest<'r> for ApiKey<'r> {
];
let student_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();
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 = "<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")]
async fn get_latest_timetable(_key: ApiKey<'_>) -> Json<Vec<timetable_connector::Timetable>> {
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])
}