- Fixed panic when only one lesson in a class

- Split authentication and timetable handlers into connectors:
  -> keycloak_connector
  -> indiware_connector
- Added standardized authentication error handler
This commit is contained in:
Denys Konovalov 2021-09-05 17:33:33 +02:00
parent 1f8e4bbd8c
commit 1d7f75b6e2
3 changed files with 442 additions and 396 deletions

185
src/indiware_connector.rs Normal file

@ -0,0 +1,185 @@
use crate::config;
use crate::schema::timetable;
use crate::DbConn;
use diesel::{Insertable, Queryable};
use quickxml_to_serde::{xml_string_to_json, Config};
use serde_derive::{Deserialize, Serialize};
use serde_json::json;
#[derive(Queryable, Serialize, Insertable, Deserialize)]
#[table_name = "timetable"]
pub struct Timetable {
pub date: String,
pub updated: String,
pub class: String,
pub timetable_data: serde_json::Value,
}
#[derive(Serialize, Deserialize)]
pub struct TimetableData {
pub count: usize,
pub courses: Vec<rocket::serde::json::Value>,
}
async fn get_timetable_xml() -> serde_json::value::Value {
let client = reqwest::Client::new();
let resp = client
.get(config::TIMETABLE_URL)
.basic_auth(config::TIMETABLE_USER, config::TIMETABLE_PASSWORD)
.send()
.await
.unwrap()
.text()
.await
.unwrap();
xml_string_to_json(resp, &Config::new_with_defaults()).unwrap()
}
async fn get_timetable_xml_data() -> Vec<serde_json::value::Value> {
let xml = get_timetable_xml().await;
let classes = xml
.as_object()
.unwrap()
.get("VpMobil")
.unwrap()
.get("Klassen")
.unwrap()
.get("Kl")
.unwrap()
.as_array()
.unwrap();
classes.to_owned()
}
pub async fn get_timetable(_conn: DbConn) -> Vec<Timetable> {
let xml = get_timetable_xml().await;
let classes = get_timetable_xml_data().await;
let mut timetable: Vec<Timetable> = Vec::new();
for i in classes.iter() {
let mut courses: Vec<rocket::serde::json::Value> = Vec::new();
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 {
courses.push(i.to_owned());
} else {
dbg!("Failed: {:?}", &i);
}
}
let response = TimetableData {
count: plan.len(),
courses,
};
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(),
),
class: String::from(
i.as_object()
.unwrap()
.get("Kurz")
.unwrap()
.as_str()
.unwrap(),
),
timetable_data: serde_json::from_str(&json!(response).to_string()).unwrap(),
};
timetable.push(timetable_element)
}
timetable
}
pub async fn get_class_timetable(_conn: DbConn, class: String) -> TimetableData {
let classes = get_timetable_xml_data().await;
let courses: Vec<rocket::serde::json::Value> = Vec::new();
let mut response = TimetableData { count: 0, courses };
for i in classes.iter() {
if i.as_object()
.unwrap()
.get("Kurz")
.unwrap()
.as_str()
.unwrap()
.replace("/", "_")
== 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())
}
response.count = plan.len();
for i in plan {
if i.as_object() != None {
response.courses.push(i.to_owned());
} else {
dbg!("Failed: {:?}", &i);
}
}
break;
}
}
response
}
pub async fn get_classes() -> Vec<String> {
let classes = get_timetable_xml_data().await;
let mut class_list: Vec<String> = Vec::new();
for i in classes.iter() {
class_list.push(String::from(
i.as_object()
.unwrap()
.get("Kurz")
.unwrap()
.as_str()
.unwrap(),
))
}
class_list
}

211
src/keycloak_connector.rs Normal file

@ -0,0 +1,211 @@
extern crate reqwest;
use crate::config;
use crate::{Claims, Credentials, Roles, Token, TokenStatus};
use jsonwebtoken::{encode, EncodingKey, Header};
use keycloak::KeycloakError;
use rocket::{response::status, serde::json::Json};
use serde_derive::{Deserialize, Serialize};
use serde_json::json;
use std::error::Error;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use time::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,
}
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", config::KC_CLIENT_ID.to_string()),
("grant_type", String::from("password")),
];
let resp = client
.post(config::KC_OPENID_TOKEN_ENDPOINT)
.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(config::KC_OPENID_USERINFO_ENDPOINT)
.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("%d/%m/%Y %T");
let my_claims = Claims {
iss: String::from(config::JWT_ISSUER),
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),
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(config::JWT_SECRET.as_ref()),
);
Ok(Json(Token {
outcome: (TokenStatus::Success, String::new()),
token: jwt.unwrap(),
}))
}
_ => Err(status::Unauthorized::<String>(Some(token.outcome.1))),
};
result
}

@ -4,49 +4,33 @@ extern crate rocket;
#[macro_use]
extern crate diesel;
mod config;
mod indiware_connector;
mod keycloak_connector;
mod schema;
extern crate reqwest;
extern crate serde;
extern crate serde_json;
use crate::schema::timetable;
use diesel::{Insertable, Queryable};
use quickxml_to_serde::{xml_string_to_json, Config};
use rocket::serde::json::{json, 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 rocket_sync_db_pools::{database, diesel::PgConnection};
use serde_derive::{Deserialize, Serialize};
extern crate serde_json;
mod config;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use keycloak::KeycloakError;
use rocket::fs::{relative, FileServer};
use rocket::http::Status;
use rocket::response::status;
use rocket::request::{FromRequest, Outcome, Request};
use std::error::Error;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use time::OffsetDateTime;
#[database("timetable")]
struct DbConn(PgConnection);
#[derive(Queryable, Serialize, Insertable, Deserialize)]
#[table_name = "timetable"]
struct Timetable {
date: String,
updated: String,
class: String,
timetable_data: serde_json::Value,
}
#[derive(Serialize, Deserialize)]
struct TimetableData {
count: usize,
courses: Vec<rocket::serde::json::Value>,
}
pub struct DbConn(PgConnection);
#[derive(Debug, Serialize, Deserialize)]
struct Credentials {
pub struct Credentials {
user: String,
password: String,
otp: String,
@ -54,7 +38,7 @@ struct Credentials {
}
#[derive(Debug, Serialize, Deserialize)]
enum Roles {
pub enum Roles {
Student,
Teacher,
Admin,
@ -80,14 +64,8 @@ enum TokenStatus {
}
#[derive(Debug, Serialize, Deserialize)]
struct TokenOutcome {
status: TokenStatus,
info: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct Token {
outcome: TokenOutcome,
pub struct Token {
outcome: (TokenStatus, String),
token: String,
}
@ -99,34 +77,6 @@ enum ApiKeyError {
Invalid,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct KeycloakAdminToken {
access_token: String,
expires_in: usize,
#[serde(rename = "not-before-policy")]
not_before_policy: Option<usize>,
refresh_expires_in: Option<usize>,
refresh_token: Option<String>,
scope: String,
session_state: Option<String>,
token_type: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct KeycloakUser {
sub: String,
email_verified: bool,
roles: Vec<Roles>,
name: String,
blacklist: Option<Vec<String>>,
groups: Vec<String>,
whitelist: Option<Vec<String>>,
preferred_username: String,
given_name: String,
family_name: String,
email: String,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for ApiKey<'r> {
type Error = ApiKeyError;
@ -134,7 +84,10 @@ impl<'r> FromRequest<'r> for ApiKey<'r> {
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 validation = Validation { validate_exp: false, ..Default::default() };
let validation = Validation {
validate_exp: false,
..Default::default()
};
let token = decode::<Claims>(
key,
&DecodingKey::from_secret(config::JWT_SECRET.as_ref()),
@ -144,7 +97,10 @@ impl<'r> FromRequest<'r> for ApiKey<'r> {
}
fn has_permissions(key: &str, uri: &str) -> bool {
let validation = Validation { validate_exp: false, ..Default::default() };
let validation = Validation {
validate_exp: false,
..Default::default()
};
let standard_permissions =
vec![String::from("/api/timetable"), String::from("/api/classes")];
let student_permissions: Vec<String> = vec![];
@ -189,348 +145,42 @@ impl<'r> FromRequest<'r> for ApiKey<'r> {
}
}
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)
}
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", config::KC_CLIENT_ID.to_string()),
("grant_type", String::from("password")),
];
let resp = client
.post(config::KC_OPENID_TOKEN_ENDPOINT)
.form(&params)
.send()
.await?;
Ok(error_check(resp).await?.json().await?)
}
async fn get_keycloak_userinfo(token: String) -> Result<KeycloakUser, Box<dyn Error>> {
let client = reqwest::Client::new();
let resp = client
.get(config::KC_OPENID_USERINFO_ENDPOINT)
.header("Authorization", format!("Bearer {}", token))
.send()
.await?
.json::<KeycloakUser>()
.await?;
Ok(resp)
#[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 get_userinfo(credentials: Json<Credentials>) -> Result<Json<KeycloakUser>, 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: TokenOutcome {
status: TokenStatus::Success,
info: String::new(),
},
token: token.access_token,
},
Err(e) => {
let outcome = match e {
KeycloakError::ReqwestFailure(f) => TokenOutcome {
status: TokenStatus::HttpError,
info: f.to_string(),
},
KeycloakError::HttpFailure {
status: _s,
body: _b,
text: t,
} => TokenOutcome {
status: TokenStatus::KeycloakError,
info: 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 outcome = match token.outcome.status {
TokenStatus::Success => Ok(Json(get_keycloak_userinfo(token.token.clone()).await.unwrap())),
_ => Err(status::Unauthorized::<()>(None))
};
outcome
}
#[post("/", data = "<credentials>")]
async fn login(credentials: Json<Credentials>) -> Json<Token> {
let credentials = credentials.into_inner();
let keycloak_resp = get_keycloak_token(
credentials.user.clone(),
credentials.password.clone(),
credentials.otp.clone(),
)
.await;
let mut token = match keycloak_resp {
Ok(token) => Token {
outcome: TokenOutcome {
status: TokenStatus::Success,
info: String::new(),
},
token: token.access_token,
},
Err(e) => {
let outcome = match e {
KeycloakError::ReqwestFailure(f) => TokenOutcome {
status: TokenStatus::HttpError,
info: f.to_string(),
},
KeycloakError::HttpFailure {
status: _s,
body: _b,
text: t,
} => TokenOutcome {
status: TokenStatus::KeycloakError,
info: 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(),
}
}
};
if let TokenStatus::Success = token.outcome.status {
let userinfo = get_keycloak_userinfo(token.token.clone()).await.unwrap();
let system_time = OffsetDateTime::now_utc();
let datetime = system_time.format("%d/%m/%Y %T");
let my_claims = Claims {
iss: String::from(config::JWT_ISSUER),
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),
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(config::JWT_SECRET.as_ref()),
);
token = Token {
outcome: TokenOutcome {
status: TokenStatus::Success,
info: String::new(),
},
token: jwt.unwrap(),
};
}
Json(token)
}
async fn get_timetable_xml() -> serde_json::value::Value {
let client = reqwest::Client::new();
let resp = client
.get(config::TIMETABLE_URL)
.basic_auth(config::TIMETABLE_USER, config::TIMETABLE_PASSWORD)
.send()
.await
.unwrap()
.text()
.await
.unwrap();
xml_string_to_json(resp, &Config::new_with_defaults()).unwrap()
}
async fn get_timetable_xml_data() -> Vec<serde_json::value::Value> {
let xml = get_timetable_xml().await;
let classes = xml
.as_object()
.unwrap()
.get("VpMobil")
.unwrap()
.get("Klassen")
.unwrap()
.get("Kl")
.unwrap()
.as_array()
.unwrap();
classes.to_owned()
async fn login(
credentials: Json<Credentials>,
) -> Result<Json<Token>, status::Unauthorized<String>> {
keycloak_connector::login(credentials).await
}
#[get("/")]
async fn get_timetable(_conn: DbConn, _key: ApiKey<'_>) -> Json<Vec<Timetable>> {
let xml = get_timetable_xml().await;
let classes = get_timetable_xml_data().await;
let mut timetable: Vec<Timetable> = Vec::new();
for i in classes.iter() {
let mut courses: Vec<rocket::serde::json::Value> = Vec::new();
let nothing = json!([""]);
let plan = i
.as_object()
.unwrap()
.get("Pl")
.unwrap()
.as_object()
.unwrap()
.get("Std")
.unwrap_or(&nothing)
.as_array()
.unwrap();
for i in plan {
if i.as_object() != None {
courses.push(i.to_owned());
} else {
dbg!("Failed: {:?}", &i);
}
}
let response = TimetableData {
count: plan.len(),
courses,
};
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(),
),
class: String::from(
i.as_object()
.unwrap()
.get("Kurz")
.unwrap()
.as_str()
.unwrap(),
),
timetable_data: serde_json::from_str(&json!(response).to_string()).unwrap(),
};
timetable.push(timetable_element)
}
async fn get_timetable(
conn: DbConn,
_key: ApiKey<'_>,
) -> Json<Vec<timetable_connector::Timetable>> {
let timetable = timetable_connector::get_timetable(conn).await;
Json::from(timetable)
}
#[get("/<class>")]
async fn get_class_timetable(
_conn: DbConn,
conn: DbConn,
class: String,
_key: ApiKey<'_>,
) -> Json<TimetableData> {
let classes = get_timetable_xml_data().await;
let courses: Vec<rocket::serde::json::Value> = Vec::new();
let mut response = TimetableData {
count: 0,
courses,
};
for i in classes.iter() {
if i.as_object()
.unwrap()
.get("Kurz")
.unwrap()
.as_str()
.unwrap()
.replace("/", "_")
== class
{
let nothing = json!([""]);
let plan = i
.as_object()
.unwrap()
.get("Pl")
.unwrap()
.as_object()
.unwrap()
.get("Std")
.unwrap_or(&nothing)
.as_array()
.unwrap();
response.count = plan.len();
for i in plan {
if i.as_object() != None {
response.courses.push(i.to_owned());
} else {
dbg!("Failed: {:?}", &i);
}
}
break;
}
}
Json::from(response)
) -> Json<timetable_connector::TimetableData> {
let timetable = timetable_connector::get_class_timetable(conn, class).await;
Json::from(timetable)
}
#[get("/")]
async fn get_classes(_key: ApiKey<'_>) -> Json<Vec<String>> {
let classes = get_timetable_xml_data().await;
let mut class_list: Vec<String> = Vec::new();
for i in classes.iter() {
class_list.push(
i.as_object()
.unwrap()
.get("Kurz")
.unwrap()
.as_str()
.unwrap()
.replace("/", "_"),
)
}
let class_list = timetable_connector::get_classes().await;
Json::from(class_list)
}