- 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)
This commit is contained in:
Denys Konovalov 2021-08-09 20:39:01 +02:00
parent 8a9ba19226
commit 1eb39cc472
2 changed files with 326 additions and 113 deletions

@ -2,6 +2,8 @@
name = "api" name = "api"
version = "0.1.0" version = "0.1.0"
edition = "2018" edition = "2018"
license = "AGPL-3.0-or-later"
authors = ["Denys Konovalov <denys.konovalov@protonmail.com>"]
# 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
@ -14,6 +16,7 @@ quickxml_to_serde = "0.4"
serde_json = "1.0" serde_json = "1.0"
jsonwebtoken = "7.2" jsonwebtoken = "7.2"
time = "0.2" time = "0.2"
keycloak = "14"
[dependencies.serde_derive] [dependencies.serde_derive]

@ -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; mod schema;
extern crate reqwest;
extern crate serde; extern crate serde;
use rocket::serde::json::{Json, json};
use crate::schema::timetable; use crate::schema::timetable;
use diesel::{Queryable, Insertable}; use diesel::{Insertable, Queryable};
use serde_derive::{Serialize, Deserialize};
use rocket_sync_db_pools::{database, diesel::PgConnection};
use reqwest;
use quickxml_to_serde::{xml_string_to_json, Config}; 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; extern crate serde_json;
mod config; mod config;
use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey}; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use std::time::{SystemTime, Duration, UNIX_EPOCH}; use keycloak::KeycloakError;
use time::OffsetDateTime; use rocket::fs::{relative, FileServer};
use rocket::http::Status; use rocket::http::Status;
use rocket::request::{self, Outcome, Request, FromRequest}; use rocket::request::{FromRequest, Outcome, Request};
use rocket::fs::{FileServer, relative}; use std::error::Error;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use time::OffsetDateTime;
#[database("timetable")] #[database("timetable")]
struct DbConn(PgConnection); struct DbConn(PgConnection);
#[derive(Queryable, Serialize, Insertable, Deserialize)] #[derive(Queryable, Serialize, Insertable, Deserialize)]
#[table_name="timetable"] #[table_name = "timetable"]
struct Timetable { struct Timetable {
date: String, date: String,
updated: String, updated: String,
class: String, class: String,
timetable_data: serde_json::Value timetable_data: serde_json::Value,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct TimetableData { struct TimetableData {
count: usize, count: usize,
courses: Vec<rocket::serde::json::Value> courses: Vec<rocket::serde::json::Value>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
struct Credentials { struct Credentials {
user: String, user: String,
password: String, password: String,
devid: String otp: String,
devid: String,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
enum Roles { enum Roles {
Student, Student,
Teacher, Teacher,
Admin Admin,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@ -61,16 +64,30 @@ struct Claims {
iss: String, iss: String,
user: String, user: String,
roles: Vec<Roles>, roles: Vec<Roles>,
// permissions: Vec<String>, groups: Vec<String>,
blacklist: Vec<String>, blacklist: Vec<String>,
whitelist: Vec<String>, whitelist: Vec<String>,
jid: String, 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)] #[derive(Debug, Serialize, Deserialize)]
struct Token { struct Token {
token: String outcome: TokenOutcome,
token: String,
} }
struct ApiKey<'r>(&'r str); struct ApiKey<'r>(&'r str);
@ -81,6 +98,34 @@ enum ApiKeyError {
Invalid, 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] #[rocket::async_trait]
impl<'r> FromRequest<'r> for ApiKey<'r> { impl<'r> FromRequest<'r> for ApiKey<'r> {
type Error = ApiKeyError; type Error = ApiKeyError;
@ -88,54 +133,185 @@ impl<'r> FromRequest<'r> for ApiKey<'r> {
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. /// Returns true if `key` is a valid API key string.
fn is_valid(key: &str) -> bool { fn is_valid(key: &str) -> bool {
let token = decode::<Claims>(&key, &DecodingKey::from_secret(config::JWT_SECRET.as_ref()), &Validation::default()); let validation = Validation { validate_exp: false, ..Default::default() };
let token = decode::<Claims>(
key,
&DecodingKey::from_secret(config::JWT_SECRET.as_ref()),
&validation,
);
token.is_ok() token.is_ok()
} }
fn has_permissions(key: &str, uri: &str) -> bool { fn has_permissions(key: &str, uri: &str) -> bool {
let student_permissions = vec![String::from("/api/classes"), String::from("/api/timetable")]; let validation = Validation { validate_exp: false, ..Default::default() };
let teacher_permissions = vec![String::from("/api/classes"), String::from("/api/timetable"), String::from("/t_timetable")]; let standard_permissions =
let token = decode::<Claims>(&key, &DecodingKey::from_secret(config::JWT_SECRET.as_ref()), &Validation::default()); vec![String::from("/api/timetable"), String::from("/api/classes")];
println!("{:?}", token); let student_permissions: Vec<String> = vec![];
let mut token = token.unwrap(); let teacher_permissions: Vec<String> = vec![];
let token = decode::<Claims>(
key,
&DecodingKey::from_secret(config::JWT_SECRET.as_ref()),
&validation,
);
let token = token.unwrap();
let mut permissions = Vec::new(); let mut permissions = Vec::new();
permissions.extend(standard_permissions.iter().cloned());
for role in token.claims.roles.iter() { for role in token.claims.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()),
Roles::Teacher => permissions.extend(teacher_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") { match req.headers().get_one("x-api-key") {
None => Outcome::Failure((Status::Unauthorized, ApiKeyError::Missing)), 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)), Some(_) => Outcome::Failure((Status::BadRequest, ApiKeyError::Invalid)),
} }
} }
} }
#[post("/", data="<credentials>")] async fn error_check(response: reqwest::Response) -> Result<reqwest::Response, KeycloakError> {
fn login(credentials: Json<Credentials>) -> Json<Token> { 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 login(credentials: Json<Credentials>) -> Json<Token> {
let credentials = credentials.into_inner(); let credentials = credentials.into_inner();
let system_time = OffsetDateTime::now_utc(); let keycloak_resp = get_keycloak_token(
let datetime = system_time.format("%d/%m/%Y %T"); credentials.user.clone(),
let my_claims = Claims { credentials.password.clone(),
iss: String::from("Georg-Cantor-Gymnasium Halle(Saale)"), credentials.otp.clone(),
user: credentials.user, )
roles: vec![Roles::Student, Roles::Admin], .await;
// permissions: vec![""] let mut token = match keycloak_resp {
blacklist: vec![String::from("/api/classes")], Ok(token) => Token {
whitelist: vec![String::from("/hello/sensitive")], outcome: TokenOutcome {
jid: String::from(credentials.devid + "@" + &datetime), status: TokenStatus::Success,
exp: SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards").as_secs() + Duration::from_secs(31536000).as_secs() 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())); if let TokenStatus::Success = token.outcome.status {
Json(Token { token: token.unwrap() }) 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 { 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 let resp = client
.get(config::TIMETABLE_URL) .get(config::TIMETABLE_URL)
.basic_auth(config::TIMETABLE_USER, config::TIMETABLE_PASSWORD) .basic_auth(config::TIMETABLE_USER, config::TIMETABLE_PASSWORD)
.send().await.unwrap() .send()
.text().await.unwrap(); .await
let xml = xml_string_to_json(resp, &Config::new_with_defaults()).unwrap(); .unwrap()
xml .text()
.await
.unwrap();
xml_string_to_json(resp, &Config::new_with_defaults()).unwrap()
} }
async fn get_timetable_xml_data() -> Vec<serde_json::value::Value> { async fn get_timetable_xml_data() -> Vec<serde_json::value::Value> {
let xml = get_timetable_xml().await; let xml = get_timetable_xml().await;
let classes = xml let classes = xml
.as_object().unwrap() .as_object()
.get("VpMobil").unwrap() .unwrap()
.get("Klassen").unwrap() .get("VpMobil")
.get("Kl").unwrap() .unwrap()
.as_array().unwrap(); .get("Klassen")
classes.to_owned().to_owned() .unwrap()
.get("Kl")
.unwrap()
.as_array()
.unwrap();
classes.to_owned()
} }
#[get("/")] #[get("/")]
@ -169,11 +353,16 @@ async fn get_timetable(_conn: DbConn, _key: ApiKey<'_>) -> Json<Vec<Timetable>>
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!([""]);
let plan = i let plan = i
.as_object().unwrap() .as_object()
.get("Pl").unwrap() .unwrap()
.as_object().unwrap() .get("Pl")
.get("Std").unwrap_or(&nothing) .unwrap()
.as_array().unwrap(); .as_object()
.unwrap()
.get("Std")
.unwrap_or(&nothing)
.as_array()
.unwrap();
for i in plan { for i in plan {
if i.as_object() != None { if i.as_object() != None {
courses.push(i.to_owned()); courses.push(i.to_owned());
@ -183,26 +372,42 @@ async fn get_timetable(_conn: DbConn, _key: ApiKey<'_>) -> Json<Vec<Timetable>>
} }
let response = TimetableData { let response = TimetableData {
count: plan.len(), count: plan.len(),
courses: courses courses,
}; };
let timetable_element = Timetable { let timetable_element = Timetable {
date: String::from(xml date: String::from(
.as_object().unwrap() xml.as_object()
.get("VpMobil").unwrap() .unwrap()
.get("Kopf").unwrap() .get("VpMobil")
.get("DatumPlan").unwrap() .unwrap()
.as_str().unwrap()), .get("Kopf")
updated: String::from(xml .unwrap()
.as_object().unwrap() .get("DatumPlan")
.get("VpMobil").unwrap() .unwrap()
.get("Kopf").unwrap() .as_str()
.get("zeitstempel").unwrap() .unwrap(),
.as_str().unwrap()), ),
class: String::from(i updated: String::from(
.as_object().unwrap() xml.as_object()
.get("Kurz").unwrap() .unwrap()
.as_str().unwrap()), .get("VpMobil")
timetable_data: serde_json::from_str(&json!(response).to_string()).unwrap() .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.push(timetable_element)
} }
@ -210,26 +415,39 @@ async fn get_timetable(_conn: DbConn, _key: ApiKey<'_>) -> Json<Vec<Timetable>>
} }
#[get("/<class>")] #[get("/<class>")]
async fn get_class_timetable(_conn: DbConn, class: String, _key: ApiKey<'_>) -> Json<TimetableData> { async fn get_class_timetable(
_conn: DbConn,
class: String,
_key: ApiKey<'_>,
) -> Json<TimetableData> {
let classes = get_timetable_xml_data().await; let classes = get_timetable_xml_data().await;
let courses: Vec<rocket::serde::json::Value> = Vec::new(); let courses: Vec<rocket::serde::json::Value> = Vec::new();
let mut response = TimetableData { let mut response = TimetableData {
count: 0, count: 0,
courses: courses courses,
}; };
for i in classes.iter() { for i in classes.iter() {
if i if i.as_object()
.as_object().unwrap() .unwrap()
.get("Kurz").unwrap() .get("Kurz")
.as_str().unwrap() .unwrap()
.replace("/", "_") == class { .as_str()
.unwrap()
.replace("/", "_")
== class
{
let nothing = json!([""]); let nothing = json!([""]);
let plan = i let plan = i
.as_object().unwrap() .as_object()
.get("Pl").unwrap() .unwrap()
.as_object().unwrap() .get("Pl")
.get("Std").unwrap_or(&nothing) .unwrap()
.as_array().unwrap(); .as_object()
.unwrap()
.get("Std")
.unwrap_or(&nothing)
.as_array()
.unwrap();
response.count = plan.len(); response.count = plan.len();
for i in plan { for i in plan {
if i.as_object() != None { if i.as_object() != None {
@ -249,36 +467,28 @@ async fn get_classes(_key: ApiKey<'_>) -> Json<Vec<String>> {
let classes = get_timetable_xml_data().await; let classes = get_timetable_xml_data().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() {
class_list.push(i.as_object().unwrap() class_list.push(
.get("Kurz").unwrap() i.as_object()
.as_str().unwrap() .unwrap()
.replace("/", "_")) .get("Kurz")
.unwrap()
.as_str()
.unwrap()
.replace("/", "_"),
)
} }
Json::from(class_list) Json::from(class_list)
} }
#[get("/")]
fn hello() -> &'static str {
"Hello, World!"
}
#[get("/<name>")]
fn hello_name(name: String) -> String {
format!("Hello, {}!", name)
}
#[get("/sensitive")]
fn sensitive(key: ApiKey<'_>) -> &'static str {
"Sensitive data."
}
#[launch] #[launch]
fn rocket() -> _ { fn rocket() -> _ {
rocket::build() rocket::build()
.attach(DbConn::fairing()) .attach(DbConn::fairing())
.mount("/", FileServer::from(relative!("static"))) .mount("/", FileServer::from(relative!("static")))
.mount("/login", routes![login]) .mount("/login", routes![login])
.mount("/hello", routes![hello, hello_name, sensitive]) .mount(
.mount("/api/timetable", routes![get_timetable, get_class_timetable]) "/api/timetable",
routes![get_timetable, get_class_timetable],
)
.mount("/api/classes", routes![get_classes]) .mount("/api/classes", routes![get_classes])
} }