// Handlers for the base auth routes use crate::{ DBConn, schema, utils:: { encode_param, new_key }, models::{ Invite, User } }; use rocket::http::Status; use rocket::response::{self, Responder, Response}; use rocket::request::{Form, Request}; use rocket_contrib::json::{Json, JsonValue}; use diesel::{self, prelude::*}; use chrono::{Duration, Utc}; use std::{error, fmt}; #[allow(dead_code)] // added because these fields are read through rocket, not directly; and rls keeps complainin #[derive(FromForm)] pub struct JoinParams { code: u64, name: String, } #[derive(FromForm, Deserialize)] pub struct AuthKey { id: u64, secret: String, } pub type AuthResult = std::result::Result; #[derive(Debug, Clone)] pub struct AuthErr { msg: &'static str, status: u16, } impl fmt::Display for AuthErr { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "Authentication error") } } impl error::Error for AuthErr { fn source(&self) -> Option<&(dyn error::Error + 'static)> { None } } impl<'r> Responder<'r> for AuthErr { fn respond_to(self, _:&Request) -> response::Result<'r> { Response::build() .status(Status::InternalServerError) .raw_header("db-error", self.msg) .ok() } } pub fn join(conn: DBConn, hashcode: u64, name: String) -> AuthResult, AuthErr>{ /* * Requires -> body * Requires -> body * Struct JoinParams enforces this for us so if something is missing then rocket should 404 */ use schema::invites::{self, dsl::*}; let diesel_result = invites .filter(invites::dsl::id.eq(hashcode)) .first::(&conn.0); if let Ok(data) = diesel_result { match data.uses { 1 ..= std::i32::MAX => { let new_user = crate::users::create_new_user(name); // At this point we don't really care about the return let _ignore = diesel::update(invites.filter(invites::dsl::id.eq(hashcode))) .set(uses.eq(data.uses - 1)) .execute(&conn.0); Ok(Json(new_user)) } // The invite has been used up and thus should be removed std::i32::MIN ..= 0 => { let _ = diesel::delete(invites.filter(invites::dsl::id.eq(data.id))) .execute(&conn.0) .expect("Could not delete invite"); Err(AuthErr{msg: "Invite expired", status: 404}) } } } else { Err(AuthErr{msg: "Malformed request", status: 500}) } } fn confirm_user_api_access(conn: &MysqlConnection, user_id: u64, user_secret: &str) -> bool { use schema::users::dsl::*; let result = users .filter(id.eq(user_id)) .filter(secret.eq(user_secret)) .first::(conn); match result { Ok(_data) => true, Err(_e) => false } } fn blind_remove_session(conn: &MysqlConnection, sesh_secret: &str) { use crate::schema::sessions::dsl::*; let _ignore_result = diesel::delete(sessions .filter(secret.eq(sesh_secret))) .execute(conn); } fn create_new_session_key(conn: &MysqlConnection) -> Option { use crate::models::InsertableSession; let new_session = InsertableSession { secret: encode_param(&new_key()), expires: (Utc::now() + Duration::hours(1)).timestamp() as u64 }; // insert the new key into our db let db_result = diesel::insert_into(schema::sessions::table) .values(&new_session) .execute(conn); // finally return the key assuming everything went well match db_result { Ok(_val) => Some(new_session.secret), Err(_e) => None } } #[post("/login", data = "")] pub fn login(conn: DBConn, api_key: Form) -> AuthResult{ /* * Session Tokens are used to key into a subset of online users * This is what should make queries faster per instance as we'll have less data to sift through w/ diesel */ if confirm_user_api_access(&conn.0, api_key.id, &api_key.secret) { blind_remove_session(&conn.0, &api_key.secret); let key = create_new_session_key(&conn.0); match key { Some(data) => Ok(json!({"key": data})), None => Err(AuthErr { msg: "Could not create session", status: 500 }) } } else { Err(AuthErr { msg: "Nothing found", status: 400 }) } } #[post("/leave", data = "")] pub fn leave(conn: DBConn, api_key: Form) -> Status { /* * Basic removal of the user from our users table */ use crate::schema::users::dsl::*; use crate::diesel::ExpressionMethods; let _db_result = diesel::delete(users .filter(id.eq(api_key.id)) .filter(secret.eq(api_key.secret.clone()))) .execute(&conn.0).unwrap(); Status::Ok } #[cfg(test)] mod auth_tests { use crate::{ invites::static_rocket_route_info_for_use_invite, schema, models::Invite, utils::{encode_param, new_key} }; use super::*; use rocket::{ self, local::Client, http::ContentType }; use diesel::mysql::MysqlConnection; use chrono::{Duration, Utc}; use rand::random; use std::env; use dotenv::dotenv; use serde_json::Value; fn setup_dotenv() -> Result<(), i32> { match dotenv() { Ok(_) => Ok(()), Err(e) => panic!("`.env` could not be loaded: {:?}", e) } } fn mysql_conn() -> MysqlConnection { MysqlConnection::establish(&env::var("DATABASE_URL").unwrap()) .unwrap() } #[test] fn join_and_leave() { // Create an invite in our db manually // Use that invite to join // Then leave using our neato /auth/leave route if let Err(_denv) = setup_dotenv() { panic!("env failed fukc") } let app = rocket::ignite() .mount("/invite", routes![use_invite]) .mount("/auth", routes![leave]) .attach(DBConn::fairing()); // First we create a new invite let conn = mysql_conn(); let dt = Utc::now() + Duration::minutes(30); let invite = Invite { id: random::(), uses: 1, expires: dt.timestamp() as u64, }; let _ = diesel::insert_into(schema::invites::table) .values(&invite) .execute(&conn); // use our new invite to "join" the server let rocket_c = Client::new(app).expect("Invalid rocket instance"); let mut response = rocket_c.get(format!("/invite/join/{}/{}", invite.id, "billybob")).dispatch(); let body: String = response.body_string().unwrap(); let api_key: Value = serde_json::from_str(&body).unwrap(); // Go about leaving the server let secret_str = format!("{}", api_key["secret"]); let body_params = format!("id={}&secret={}", api_key["id"], secret_str); println!("Parameters being sent {}", body_params); let leave_response = rocket_c.post("/auth/leave") .body(body_params) .header(ContentType::Form) .dispatch(); assert_eq!(leave_response.status(), Status::Ok); println!("{}", body); } #[test] fn dummy_leave() { /* * Naive test for the /auth/leave route */ setup_dotenv().unwrap(); let app = rocket::ignite() .mount("/auth", routes![leave]) .attach(DBConn::fairing()); let rocket_client = Client::new(app).expect("asdf"); // Some dummy parameters as the /auth/leave route only has one type of response let id = 12345; let secret = encode_param(&new_key()); let params = format!("id={}&secret={}", id, secret); println!("Parameters posted to /auth/leave: {}", params); let response = rocket_client.post("/auth/leave") .body(params) .header(ContentType::Form) .dispatch(); assert_eq!(response.status(), Status::Ok); } fn bogus_user_insertion(test_name: String, conn: &MysqlConnection) -> User { use crate::models::{USER_OFFLINE, InsertableUser}; use schema::users::{self, dsl::*}; let insertable_user = InsertableUser { name: test_name, secret: encode_param(&new_key()), date: Utc::now().timestamp() as u64, status: USER_OFFLINE }; let _insertion_result = diesel::insert_into(users::table) .values(&insertable_user) .execute(conn); users.filter(date.eq(insertable_user.date)).first::(conn).unwrap() } #[test] fn login() { setup_dotenv().unwrap(); let app = rocket::ignite() .mount("/auth", routes![login]) .attach(DBConn::fairing()); let conn = mysql_conn(); // We need a valid user to login so we'll make one up let bogus_user = bogus_user_insertion("test::auth::login".to_string(), &conn); // Finaly we can test our route with the boilerplate ootw let rocket_client = Client::new(app).expect("test::auth::login => client creation failed"); let params = format!("id={}&secret={}", bogus_user.id, bogus_user.secret); let response = rocket_client.post("/auth/login") .body(params) .header(ContentType::Form) .dispatch(); assert_eq!(response.status(), Status::Ok); } }