diff --git a/api/src/guard/candidate_jwt.rs b/api/src/guard/candidate_jwt.rs new file mode 100644 index 0000000..25d9486 --- /dev/null +++ b/api/src/guard/candidate_jwt.rs @@ -0,0 +1,33 @@ +use rocket::http::Status; +use rocket::outcome::Outcome; +use rocket::request::{FromRequest, Request}; + +use portfolio_core::token::candidate_token::CandidateToken; +use portfolio_core::token::decode_candidate_token; + +pub struct TokenRequest(CandidateToken); + +impl TokenRequest { + pub fn to_token(self) -> CandidateToken { + self.0 + } +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for TokenRequest { + type Error = Status; + async fn from_request(req: &'r Request<'_>) -> Outcome { + if let Some(auth) = req.headers().get_one("Authorization") { + let auth_string = auth.to_string(); + if auth_string.starts_with("Bearer") { + let token = auth_string[6..auth_string.len()].trim(); + let token_data = decode_candidate_token(token.to_string()); + + if token_data.is_ok() { + return Outcome::Success(TokenRequest(token_data.ok().unwrap().claims)); + } + } + } + return Outcome::Failure((Status::Unauthorized, Status::Unauthorized)); + } +} diff --git a/api/src/guard/mod.rs b/api/src/guard/mod.rs new file mode 100644 index 0000000..f2318e8 --- /dev/null +++ b/api/src/guard/mod.rs @@ -0,0 +1 @@ +pub mod candidate_jwt; \ No newline at end of file diff --git a/api/src/lib.rs b/api/src/lib.rs index 5ba1fbd..b4d16c9 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1,5 +1,11 @@ #[macro_use] extern crate rocket; + +use guard::candidate_jwt::TokenRequest; +use portfolio_core::error::ServiceError; +use portfolio_core::services::candidate_service::CandidateService; +use requests::LoginRequest; +use rocket::http::Status; use rocket::{Rocket, Build}; use rocket::serde::json::Json; use rocket::fairing::{self, AdHoc}; @@ -9,7 +15,11 @@ use portfolio_core::{Mutation}; use migration::{MigratorTrait}; use sea_orm_rocket::{Connection, Database}; + mod pool; +mod guard; +mod requests; + use pool::Db; pub use entity::candidate; @@ -17,6 +27,9 @@ pub use entity::candidate::Entity as Candidate; use portfolio_core::crypto::random_8_char_string; +fn custom_err_from_service_err(service_err: ServiceError) -> Custom { + Custom(Status::from_code(service_err.0.code).unwrap_or_default(), service_err.1.to_string()) +} #[post("/", data = "")] async fn create(conn: Connection<'_, Db>, post_form: Json) -> Result> { @@ -32,6 +45,38 @@ async fn create(conn: Connection<'_, Db>, post_form: Json) -> Ok(plain_text_password) } +#[post("/login", data = "")] +async fn login(conn: Connection<'_, Db>, login_form: Json) -> Result> { + let db = conn.into_inner(); + println!("{} {}", login_form.application_id, login_form.password); + + let jwt = CandidateService::login(db, + login_form.application_id, + login_form.password.to_owned()).await; + + if jwt.is_ok() { + return Ok( + jwt.ok().unwrap() + ); + } else { + return Err( + custom_err_from_service_err(jwt.err().unwrap()) + ) + } +} + +#[get("/whoami")] +async fn whoami(conn: Connection<'_, Db>, token_req: Result) -> Result> { + let db = conn.into_inner(); + let token = token_req.ok().unwrap().to_token(); + let user = CandidateService::authenticate_candidate(db, token).await; + + match user { + Ok(user) => Ok(format!("{} {}", user.name.unwrap(), user.surname.unwrap())), + Err(e) => Err(custom_err_from_service_err(e)), + } +} + #[get("/hello")] async fn hello() -> &'static str { "Hello, world!" @@ -49,7 +94,7 @@ async fn start() -> Result<(), rocket::Error> { .attach(Db::init()) .attach(AdHoc::try_on_ignite("Migrations", run_migrations)) //.mount("/", FileServer::from(relative!("/static"))) - .mount("/", routes![create, hello]) + .mount("/", routes![create, login, hello, whoami]) .register("/", catchers![]) .launch() .await diff --git a/api/src/requests.rs b/api/src/requests.rs new file mode 100644 index 0000000..9c86d49 --- /dev/null +++ b/api/src/requests.rs @@ -0,0 +1,9 @@ +use rocket::serde::{Serialize, Deserialize}; + + +#[derive(Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct LoginRequest { + pub application_id: i32, + pub password: String, +} \ No newline at end of file diff --git a/core/Cargo.toml b/core/Cargo.toml index 4b41cec..ad9a9b0 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -4,10 +4,13 @@ version = "0.1.0" edition = "2021" [dependencies] +serde = { version = "1.0", features = ["derive"] } portfolio-entity = { path = "../entity" } rand = "0.8.5" argon2 = "0.4.1" chrono = "0.4.22" +jsonwebtoken = "8.1.1" +dotenv = "0.15.0" [dependencies.sea-orm] version = "^0.10.0" diff --git a/core/src/error.rs b/core/src/error.rs new file mode 100644 index 0000000..b2093a7 --- /dev/null +++ b/core/src/error.rs @@ -0,0 +1,18 @@ +pub struct Status { + pub code: u16, +} + +pub const INVALID_CREDENTIALS_ERROR: ServiceError = ServiceError(Status { code: 401 }, + "Invalid credentials"); +pub const JWT_ERROR: ServiceError = ServiceError(Status { code: 500 }, + "Error while encoding JWT"); + +pub const USER_NOT_FOUND_ERROR: ServiceError = ServiceError(Status { code: 404 }, + "User not found"); + +pub const DB_ERROR: ServiceError = ServiceError(Status { code: 500 }, + "Database error"); + +pub const USER_NOT_FOUND_BY_JWT_ID: ServiceError = ServiceError(Status { code: 500 }, // User got somehow + "User not found, please contact technical support"); // Shouldn't ever happen +pub struct ServiceError<'a>(pub Status, pub &'a str); \ No newline at end of file diff --git a/core/src/lib.rs b/core/src/lib.rs index 0ebcef0..71b5ace 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,6 +1,9 @@ mod mutation; mod query; pub mod crypto; +pub mod token; +pub mod services; +pub mod error; pub use mutation::*; pub use query::*; diff --git a/core/src/services/candidate_service.rs b/core/src/services/candidate_service.rs new file mode 100644 index 0000000..3ba2e8b --- /dev/null +++ b/core/src/services/candidate_service.rs @@ -0,0 +1,43 @@ +use entity::candidate; +use sea_orm::DatabaseConnection; + +use crate::{crypto, Query, token::{generate_candidate_token, candidate_token::CandidateToken}, error::{ServiceError, USER_NOT_FOUND_ERROR, INVALID_CREDENTIALS_ERROR, DB_ERROR, USER_NOT_FOUND_BY_JWT_ID}}; + +pub struct CandidateService; + +impl CandidateService { + + pub async fn login(db: &DatabaseConnection, id: i32, password: String) -> Result { + let candidate = match Query::find_candidate_by_id(db, id).await { + Ok(candidate) => match candidate { + Some(candidate) => candidate, + None => return Err(USER_NOT_FOUND_ERROR) + }, + Err(_) => {return Err(DB_ERROR)} + }; + + + let valid = crypto::verify_password(&password,&candidate.code ) + .expect("Invalid password"); + + if !valid { + return Err(INVALID_CREDENTIALS_ERROR) + } + + let jwt = generate_candidate_token(candidate); // TODO better error handling + Ok(jwt) + + } + + pub async fn authenticate_candidate(db: &DatabaseConnection, token: CandidateToken) -> Result { + let candidate = match Query::find_candidate_by_id(db, token.application_id).await { + Ok(candidate) => match candidate { + Some(candidate) => candidate, + None => return Err(USER_NOT_FOUND_BY_JWT_ID) + }, + Err(_) => {return Err(DB_ERROR)} + }; + + Ok(candidate) + } +} diff --git a/core/src/services/mod.rs b/core/src/services/mod.rs new file mode 100644 index 0000000..1992ce2 --- /dev/null +++ b/core/src/services/mod.rs @@ -0,0 +1 @@ +pub mod candidate_service; \ No newline at end of file diff --git a/core/src/token/admin_token.rs b/core/src/token/admin_token.rs new file mode 100644 index 0000000..73b7a5f --- /dev/null +++ b/core/src/token/admin_token.rs @@ -0,0 +1,9 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct AdminToken { + // issued at + pub iat: i64, + // expiration + pub exp: i64, +} \ No newline at end of file diff --git a/core/src/token/candidate_token.rs b/core/src/token/candidate_token.rs new file mode 100644 index 0000000..7014965 --- /dev/null +++ b/core/src/token/candidate_token.rs @@ -0,0 +1,26 @@ +use chrono::Utc; +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct CandidateToken { + // issued at + pub iat: i64, + // expiration + pub exp: i64, + pub application_id: i32, + pub name: String, + pub surname: String, +} + +impl CandidateToken { + pub fn generate(application_id: i32, name: String, surname: String) -> Self { + let now = Utc::now().timestamp(); + CandidateToken { + iat: now, + exp: now + 60 * 60, // 1 hour for now + application_id, + name, + surname, + } + } +} \ No newline at end of file diff --git a/core/src/token/mod.rs b/core/src/token/mod.rs new file mode 100644 index 0000000..370f28f --- /dev/null +++ b/core/src/token/mod.rs @@ -0,0 +1,98 @@ +pub mod admin_token; +pub mod candidate_token; + +use chrono::Utc; + +use entity::{admin, candidate}; +use jsonwebtoken::errors::Result; +use jsonwebtoken::TokenData; +use jsonwebtoken::{DecodingKey, EncodingKey}; +use jsonwebtoken::{Header, Validation}; + +use admin_token::AdminToken; +use candidate_token::CandidateToken; +use serde::Deserialize; + +const ONE_WEEK: i64 = 60 * 60 * 24 * 7; + +pub fn generate_candidate_token(candidate: candidate::Model) -> String { + let now = Utc::now().timestamp(); + let payload = CandidateToken { + iat: now, + exp: now + ONE_WEEK, + application_id: candidate.application, + name: candidate.name.unwrap_or_else(|| "".into()), + surname: candidate.surname.unwrap_or_else(|| "".into()), + }; + + jsonwebtoken::encode( + &Header::default(), + &payload, + &EncodingKey::from_secret(include_bytes!("secret.key")), + ) + .unwrap() +} + +pub fn generate_admin_token(_admin: admin::Model) -> String { + let now = Utc::now().timestamp(); + let payload = AdminToken { + iat: now, + exp: now + ONE_WEEK, + }; + + jsonwebtoken::encode( + &Header::default(), + &payload, + &EncodingKey::from_secret(include_bytes!("secret.key")), + ) + .unwrap() +} + +pub fn decode_token Deserialize<'a>>(token: String) -> Result> { + jsonwebtoken::decode::( + &token, + &DecodingKey::from_secret(include_bytes!("secret.key")), + &Validation::default(), + ) +} + +pub fn decode_candidate_token(token: String) -> Result> { + decode_token(token) +} + +pub fn decode_admin_token(token: String) -> Result> { + decode_token(token) +} + + +#[test] +fn test_encode_decode_token() { + let candidate_model = candidate::Model { + application: 101204, + code: "random_code".to_string(), + birth_surname: None, + birthplace: None, + birthdate: None, + address: None, + telephone: None, + citizenship: None, + sex: None, + study: None, + personal_identification_number: None, + personal_identification_number_hash: None, + public_key: "None".to_owned(), + private_key: "None".to_owned(), + created_at: Utc::now().naive_local(), + updated_at: Utc::now().naive_local(), + name: Some("Uplnej".to_string()), + surname: Some("Magor".to_string()), + email: Some("email.uchazece@centrum.cz".to_string()), + }; + + let jwt = generate_candidate_token(candidate_model.clone()); + + let decoded = decode_candidate_token(jwt).unwrap(); + let token_claims = decoded.claims; + assert_eq!(candidate_model.name.unwrap(), token_claims.name); + assert_eq!(candidate_model.surname.unwrap(), token_claims.surname); +} \ No newline at end of file diff --git a/core/src/token/secret.key b/core/src/token/secret.key new file mode 100644 index 0000000..3602361 --- /dev/null +++ b/core/src/token/secret.key @@ -0,0 +1 @@ +temp \ No newline at end of file diff --git a/entity/src/mod.rs b/entity/src/mod.rs new file mode 100644 index 0000000..63cf621 --- /dev/null +++ b/entity/src/mod.rs @@ -0,0 +1,8 @@ +//! SeaORM Entity. Generated by sea-orm-codegen 0.9.3 + +pub mod prelude; + +pub mod admin; +pub mod candidate; +pub mod parent; +pub mod session;