From 1eb39cc472e368a5f2badaeedb4b50e7b686d9b8 Mon Sep 17 00:00:00 2001 From: Denys Konovalov Date: Mon, 9 Aug 2021 20:39:01 +0200 Subject: [PATCH] - added Keycloak login service - removed obsolete testing endpoints - extended jwt parameters - moved some values to config - added Cargo.tom information - correcred code (cargo fmt & cargo clippy) --- Cargo.toml | 3 + src/main.rs | 436 ++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 326 insertions(+), 113 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2bcefca..2ca3934 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,8 @@ name = "api" version = "0.1.0" edition = "2018" +license = "AGPL-3.0-or-later" +authors = ["Denys Konovalov "] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -14,6 +16,7 @@ quickxml_to_serde = "0.4" serde_json = "1.0" jsonwebtoken = "7.2" time = "0.2" +keycloak = "14" [dependencies.serde_derive] diff --git a/src/main.rs b/src/main.rs index 65a85ba..ccc0431 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,59 +1,62 @@ -#[macro_use] extern crate rocket; +#[macro_use] +extern crate rocket; -#[macro_use] extern crate diesel; +#[macro_use] +extern crate diesel; mod schema; +extern crate reqwest; extern crate serde; - -use rocket::serde::json::{Json, json}; use crate::schema::timetable; -use diesel::{Queryable, Insertable}; -use serde_derive::{Serialize, Deserialize}; -use rocket_sync_db_pools::{database, diesel::PgConnection}; -use reqwest; +use diesel::{Insertable, Queryable}; use quickxml_to_serde::{xml_string_to_json, Config}; +use rocket::serde::json::{json, Json}; +use rocket_sync_db_pools::{database, diesel::PgConnection}; +use serde_derive::{Deserialize, Serialize}; extern crate serde_json; mod config; -use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey}; -use std::time::{SystemTime, Duration, UNIX_EPOCH}; -use time::OffsetDateTime; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use keycloak::KeycloakError; +use rocket::fs::{relative, FileServer}; use rocket::http::Status; -use rocket::request::{self, Outcome, Request, FromRequest}; -use rocket::fs::{FileServer, relative}; - +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"] +#[table_name = "timetable"] struct Timetable { date: String, updated: String, class: String, - timetable_data: serde_json::Value + timetable_data: serde_json::Value, } #[derive(Serialize, Deserialize)] struct TimetableData { count: usize, - courses: Vec + courses: Vec, } #[derive(Debug, Serialize, Deserialize)] struct Credentials { user: String, password: String, - devid: String + otp: String, + devid: String, } #[derive(Debug, Serialize, Deserialize)] enum Roles { Student, Teacher, - Admin + Admin, } #[derive(Debug, Serialize, Deserialize)] @@ -61,16 +64,30 @@ struct Claims { iss: String, user: String, roles: Vec, - // permissions: Vec, + groups: Vec, blacklist: Vec, whitelist: Vec, jid: String, - exp: u64 + exp: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +enum TokenStatus { + Success, + KeycloakError, + HttpError, +} + +#[derive(Debug, Serialize, Deserialize)] +struct TokenOutcome { + status: TokenStatus, + info: String, } #[derive(Debug, Serialize, Deserialize)] struct Token { - token: String + outcome: TokenOutcome, + token: String, } struct ApiKey<'r>(&'r str); @@ -81,6 +98,34 @@ 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, + refresh_expires_in: Option, + refresh_token: Option, + scope: String, + session_state: Option, + token_type: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct KeycloakUser { + sub: String, + email_verified: bool, + roles: Vec, + name: String, + blacklist: Option>, + groups: Vec, + whitelist: Option>, + preferred_username: String, + given_name: String, + family_name: String, + email: String, +} + #[rocket::async_trait] impl<'r> FromRequest<'r> for ApiKey<'r> { type Error = ApiKeyError; @@ -88,54 +133,185 @@ impl<'r> FromRequest<'r> for ApiKey<'r> { 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 token = decode::(&key, &DecodingKey::from_secret(config::JWT_SECRET.as_ref()), &Validation::default()); + let validation = Validation { validate_exp: false, ..Default::default() }; + let token = decode::( + key, + &DecodingKey::from_secret(config::JWT_SECRET.as_ref()), + &validation, + ); token.is_ok() } fn has_permissions(key: &str, uri: &str) -> bool { - let student_permissions = vec![String::from("/api/classes"), String::from("/api/timetable")]; - let teacher_permissions = vec![String::from("/api/classes"), String::from("/api/timetable"), String::from("/t_timetable")]; - let token = decode::(&key, &DecodingKey::from_secret(config::JWT_SECRET.as_ref()), &Validation::default()); - println!("{:?}", token); - let mut token = token.unwrap(); + let validation = Validation { validate_exp: false, ..Default::default() }; + let standard_permissions = + vec![String::from("/api/timetable"), String::from("/api/classes")]; + let student_permissions: Vec = vec![]; + let teacher_permissions: Vec = vec![]; + let token = decode::( + key, + &DecodingKey::from_secret(config::JWT_SECRET.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() { match role { Roles::Admin => permissions.push(String::from("all")), Roles::Student => permissions.extend(student_permissions.iter().cloned()), Roles::Teacher => permissions.extend(teacher_permissions.iter().cloned()), - _ => permissions.push(String::from("none")) } } - permissions.contains(&String::from(uri)) | permissions.contains(&String::from("all")) | &token.claims.whitelist.contains(&String::from(uri)) && !token.claims.blacklist.contains(&String::from(uri)) + permissions.contains(&String::from(uri)) + | permissions.contains(&String::from("all")) + | token.claims.whitelist.contains(&String::from(uri)) + && !token.claims.blacklist.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.uri().path().as_str()) => Outcome::Success(ApiKey(key)), + Some(key) + if is_valid(key) + && has_permissions(key, req.route().unwrap().uri.base.path().as_str()) => + { + Outcome::Success(ApiKey(key)) + } + 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="")] -fn login(credentials: Json) -> Json { +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) +} + +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", config::KC_CLIENT_ID.to_string()), + ("grant_type", String::from("password")), + ]; + let resp = client + .post(config::KC_OPENID_TOKEN_ENDPOINT) + .form(¶ms) + .send() + .await?; + Ok(error_check(resp).await?.json().await?) +} + +async fn get_keycloak_userinfo(token: String) -> Result> { + let client = reqwest::Client::new(); + let resp = client + .get(config::KC_OPENID_USERINFO_ENDPOINT) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await? + .json::() + .await?; + Ok(resp) +} + +#[post("/", data = "")] +async fn login(credentials: Json) -> Json { let credentials = credentials.into_inner(); - let system_time = OffsetDateTime::now_utc(); - let datetime = system_time.format("%d/%m/%Y %T"); - let my_claims = Claims { - iss: String::from("Georg-Cantor-Gymnasium Halle(Saale)"), - user: credentials.user, - roles: vec![Roles::Student, Roles::Admin], - // permissions: vec![""] - blacklist: vec![String::from("/api/classes")], - whitelist: vec![String::from("/hello/sensitive")], - jid: String::from(credentials.devid + "@" + &datetime), - exp: SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards").as_secs() + Duration::from_secs(31536000).as_secs() + 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(), + } + } }; - println!("{:?}", SystemTime::now()); - let token = encode(&Header::default(), &my_claims, &EncodingKey::from_secret(config::JWT_SECRET.as_ref())); - Json(Token { token: token.unwrap() }) + + 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 { @@ -143,21 +319,29 @@ async fn get_timetable_xml() -> serde_json::value::Value { let resp = client .get(config::TIMETABLE_URL) .basic_auth(config::TIMETABLE_USER, config::TIMETABLE_PASSWORD) - .send().await.unwrap() - .text().await.unwrap(); - let xml = xml_string_to_json(resp, &Config::new_with_defaults()).unwrap(); - xml + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + xml_string_to_json(resp, &Config::new_with_defaults()).unwrap() } async fn get_timetable_xml_data() -> Vec { 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().to_owned() + .as_object() + .unwrap() + .get("VpMobil") + .unwrap() + .get("Klassen") + .unwrap() + .get("Kl") + .unwrap() + .as_array() + .unwrap(); + classes.to_owned() } #[get("/")] @@ -169,11 +353,16 @@ async fn get_timetable(_conn: DbConn, _key: ApiKey<'_>) -> Json> let mut courses: Vec = Vec::new(); let nothing = json!([""]); let plan = i - .as_object().unwrap() - .get("Pl").unwrap() - .as_object().unwrap() - .get("Std").unwrap_or(¬hing) - .as_array().unwrap(); + .as_object() + .unwrap() + .get("Pl") + .unwrap() + .as_object() + .unwrap() + .get("Std") + .unwrap_or(¬hing) + .as_array() + .unwrap(); for i in plan { if i.as_object() != None { courses.push(i.to_owned()); @@ -183,26 +372,42 @@ async fn get_timetable(_conn: DbConn, _key: ApiKey<'_>) -> Json> } let response = TimetableData { count: plan.len(), - courses: courses + 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() + 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) } @@ -210,26 +415,39 @@ async fn get_timetable(_conn: DbConn, _key: ApiKey<'_>) -> Json> } #[get("/")] -async fn get_class_timetable(_conn: DbConn, class: String, _key: ApiKey<'_>) -> Json { +async fn get_class_timetable( + _conn: DbConn, + class: String, + _key: ApiKey<'_>, +) -> Json { let classes = get_timetable_xml_data().await; let courses: Vec = Vec::new(); let mut response = TimetableData { count: 0, - courses: courses + courses, }; for i in classes.iter() { - if i - .as_object().unwrap() - .get("Kurz").unwrap() - .as_str().unwrap() - .replace("/", "_") == class { + 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(¬hing) - .as_array().unwrap(); + .as_object() + .unwrap() + .get("Pl") + .unwrap() + .as_object() + .unwrap() + .get("Std") + .unwrap_or(¬hing) + .as_array() + .unwrap(); response.count = plan.len(); for i in plan { if i.as_object() != None { @@ -249,36 +467,28 @@ async fn get_classes(_key: ApiKey<'_>) -> Json> { let classes = get_timetable_xml_data().await; let mut class_list: Vec = Vec::new(); for i in classes.iter() { - class_list.push(i.as_object().unwrap() - .get("Kurz").unwrap() - .as_str().unwrap() - .replace("/", "_")) + class_list.push( + i.as_object() + .unwrap() + .get("Kurz") + .unwrap() + .as_str() + .unwrap() + .replace("/", "_"), + ) } Json::from(class_list) } -#[get("/")] -fn hello() -> &'static str { - "Hello, World!" -} - -#[get("/")] -fn hello_name(name: String) -> String { - format!("Hello, {}!", name) -} - -#[get("/sensitive")] -fn sensitive(key: ApiKey<'_>) -> &'static str { - "Sensitive data." -} - #[launch] fn rocket() -> _ { rocket::build() .attach(DbConn::fairing()) .mount("/", FileServer::from(relative!("static"))) .mount("/login", routes![login]) - .mount("/hello", routes![hello, hello_name, sensitive]) - .mount("/api/timetable", routes![get_timetable, get_class_timetable]) + .mount( + "/api/timetable", + routes![get_timetable, get_class_timetable], + ) .mount("/api/classes", routes![get_classes]) }