From 6361532c1ed57caad7a0a4ebbf87a80e2a85172d Mon Sep 17 00:00:00 2001 From: Elias Renman Date: Thu, 20 Mar 2025 10:09:57 +0100 Subject: [PATCH] feat: github & google login --- .DS_Store | Bin 0 -> 6148 bytes .env.example | 2 + .gitignore | 1 + Cargo.lock | 1332 ++++++++++++++++- Cargo.toml | 11 +- Rocket.toml.example | 14 + .../2025-03-16-110640_url_entry/down.sql | 2 +- migrations/2025-03-16-110640_url_entry/up.sql | 2 +- .../down.sql | 2 + .../2025-03-17-125909_magic_email_link/up.sql | 7 + src/.DS_Store | Bin 0 -> 6148 bytes src/auth/.DS_Store | Bin 0 -> 6148 bytes src/auth/email.rs | 48 + src/auth/github.rs | 72 + src/auth/google.rs | 80 + src/auth/jwts.rs | 56 + src/auth/mod.rs | 4 + src/db/mod.rs | 3 - src/db/models.rs | 1 + src/db/schema.rs | 2 +- src/db/url.rs | 14 +- src/handlers/url.rs | 4 +- src/main.rs | 51 +- 23 files changed, 1664 insertions(+), 44 deletions(-) create mode 100644 .DS_Store create mode 100644 Rocket.toml.example create mode 100644 migrations/2025-03-17-125909_magic_email_link/down.sql create mode 100644 migrations/2025-03-17-125909_magic_email_link/up.sql create mode 100644 src/.DS_Store create mode 100644 src/auth/.DS_Store create mode 100644 src/auth/email.rs create mode 100644 src/auth/github.rs create mode 100644 src/auth/google.rs create mode 100644 src/auth/jwts.rs create mode 100644 src/auth/mod.rs diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..0440c4994d1d30fb6a0d36b9c76a4c28ec3fc0ff GIT binary patch literal 6148 zcmeHKO;6)65PhZ}x$G{$d28-x=lhq25ekn_`PS5(tcB6%g4-#oFMfue>_YJ z1B{Sh#*#A9N|ZVDP!0TKc4K1hiB6GJqnH=ZzsXES97|5roUIH~%sk9r57WXg_ti>> zS(Dk;a5L27ymz&j&2xI6;8sVQv+n4qN6c!*+!AIrVS7q@pRotDNGnx_rp!VRkF*L_ z#?WP72Ma!@IHK)=)|`IwCh_Vp-kNbRqidJZ8V_RkS93r%68vWL3A0&ZU)RNJXh7bPi=++MLy1b>%_!M47J|5jpx*FWm!WuWin=-Jx8cp0BL` zvTqH~jP1oWvf|5Le3Ua=q|vfE)PaG(Kw#j50r@^eETUtl3#;|h!Ah?H)ODIw*p}Y~ z!9-5SP8U|Qhhi+0+Cq)HVi*gjJ#v1r(}mR*4&yE##zi*nhGH~2-yf+vOl)S5Z<-XrW7Fug&r5Y7ObWU;w8lT0!H+pQWH{aFlI}W*h4AgtS{t~_&m<+ zZp31}ir5+0{pNQ!`$6`HF~l-IVvjz-77;ICK-|A7||@BM5&CxelxMZ z4*2aB3t7nuHvank(IidsqSt%p8x3o7t7$jwwtW|jBaCdeahsECX@r;O12Hw@CwT3dY*%6mdfk_e06@EM@S411H=F^u)YkK zGr?}JF9o!6Vt^RbO81@;rl;MjQqG RsvM9m0*Vmoh=E^V-~$1$N?-s0 literal 0 HcmV?d00001 diff --git a/src/auth/.DS_Store b/src/auth/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a99719331de1c7b0b376c8b6c8097ab5e49c8941 GIT binary patch literal 6148 zcmeHK%TB{E5FA4VEnIr!f|MU-vug1D2kRSODDm8RmFJ0HXMae`Uf>LUr_ z1gg-kw4TjA9D9=3F#t1IjW2*MfHqaIb;71WBraN$1Mle&jqM{vA32scX*Q~5t3w%3 z2KJ2s`F7iw-~}7nS4fTckjW#CU4_yQbaqSXKZ literal 0 HcmV?d00001 diff --git a/src/auth/email.rs b/src/auth/email.rs new file mode 100644 index 0000000..e915934 --- /dev/null +++ b/src/auth/email.rs @@ -0,0 +1,48 @@ +use anyhow::Error; +use rocket::fairing::{AdHoc, Fairing}; +use rocket::get; +use rocket::http::{CookieJar, Status}; +use rocket::response::Debug; +use rocket::response::Redirect; + +use serde::{Deserialize, Serialize}; + +use crate::User; + +#[derive(Deserialize, Serialize)] +struct EmailUserInfo { + email: String, +} +impl From for User { + fn from(info: EmailUserInfo) -> Self { + User { + id: info.email.clone(), + username: info.email, + } + } +} +#[get("/login/email")] +fn email_login<'a>(cookies: &'a CookieJar<'a>) -> (Status, &'a str) { + // Create magic link + // Send email containing magic link + (Status::Ok, "Email link sent") +} + +#[get("/auth/email")] +async fn email_callback(cookies: &CookieJar<'_>) -> Result> { + // Validate magic link + // create token + // Set a cookie with the user's name, and redirect to the home page. + // cookies.add( + // Cookie::build(("access_token", create_user_token(user_info.into()).unwrap())) + // .same_site(SameSite::Lax) + // .build(), + // ); + Ok(Redirect::to("/")) +} + +pub fn email_fairing() -> impl Fairing { + AdHoc::on_ignite("Email Auth", |rocket| async { + rocket.mount("/api", rocket::routes![email_login, email_callback]) + }) +} diff --git a/src/auth/github.rs b/src/auth/github.rs new file mode 100644 index 0000000..ed54971 --- /dev/null +++ b/src/auth/github.rs @@ -0,0 +1,72 @@ +use super::jwts::{create_user_token, generate_id}; +use crate::User; +use anyhow::{Context, Error}; +use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT}; +use rocket::fairing::{AdHoc, Fairing}; +use rocket::get; +use rocket::http::{Cookie, CookieJar, SameSite}; +use rocket::response::Debug; +use rocket::response::Redirect; +use rocket_oauth2::{OAuth2, TokenResponse}; +use serde::{Deserialize, Serialize}; + +/// User information to be retrieved from the GitHub API. +#[derive(Deserialize, Serialize)] +struct GitHubUserInfo { + name: String, + id: i64, +} +impl From for User { + fn from(info: GitHubUserInfo) -> Self { + User { + username: info.name, + id: generate_id("github", format!("{}", info.id).as_str()), + } + } +} +// NB: Here we are using the same struct as a type parameter to OAuth2 and +// TokenResponse as we use for the user's GitHub login details. For +// `TokenResponse` and `OAuth2` the actual type does not matter; only that they +// are matched up. +#[get("/login/github")] +fn github_login(oauth2: OAuth2, cookies: &CookieJar<'_>) -> Redirect { + oauth2 + .get_redirect(cookies, &["user:read", "user:email"]) + .unwrap() +} + +#[get("/auth/github")] +async fn github_callback( + token: TokenResponse, + cookies: &CookieJar<'_>, +) -> Result> { + // Use the token to retrieve the user's GitHub account information. + let user: GitHubUserInfo = reqwest::Client::builder() + .build() + .context("failed to build reqwest client")? + .get("https://api.github.com/user") + .header(AUTHORIZATION, format!("token {}", token.access_token())) + .header(ACCEPT, "application/vnd.github.v3+json") + .header(USER_AGENT, "rocket_oauth2 demo application") + .send() + .await + .context("failed to complete request")? + .json() + .await + .context("failed to deserialize response")?; + + // Set a cookie with the user's name, and redirect to the home page. + cookies.add( + Cookie::build(("access_token", create_user_token(user.into()).unwrap())) + .same_site(SameSite::Lax) + .build(), + ); + Ok(Redirect::to("/")) +} +pub fn github_fairing() -> impl Fairing { + AdHoc::on_ignite("Github OAuth2", |rocket| async { + rocket + .mount("/", rocket::routes![github_login, github_callback]) + .attach(OAuth2::::fairing("github")) + }) +} diff --git a/src/auth/google.rs b/src/auth/google.rs new file mode 100644 index 0000000..6c096e8 --- /dev/null +++ b/src/auth/google.rs @@ -0,0 +1,80 @@ +use anyhow::{Context, Error}; +use reqwest::header::AUTHORIZATION; +use rocket::fairing::{AdHoc, Fairing}; +use rocket::get; +use rocket::http::{Cookie, CookieJar, SameSite}; +use rocket::response::{Debug, Redirect}; +use rocket::serde::json::Value; +use rocket_oauth2::{OAuth2, TokenResponse}; + +use crate::User; + +use super::jwts::{create_user_token, generate_id}; + +/// User information to be retrieved from the Google People API. +#[derive(serde::Deserialize)] +#[serde(rename_all = "snake_case")] +struct GoogleUserInfo { + names: Vec, + resource_name: String, +} +impl From for User { + fn from(info: GoogleUserInfo) -> Self { + User { + username: info + .names + .iter() + .find_map(|name| { + let metadata = name.get("metadata")?; + let primary = metadata.get("primary")?.as_bool()?; + + if primary { + name.get("displayName")?.as_str().map(|s| s.to_string()) + } else { + None + } + }) + .unwrap(), + id: generate_id("github", format!("{}", info.resource_name).as_str()), + } + } +} +#[get("/login/google")] +fn google_login(oauth2: OAuth2, cookies: &CookieJar<'_>) -> Redirect { + oauth2.get_redirect(cookies, &["profile"]).unwrap() +} + +#[get("/auth/google")] +async fn google_callback( + token: TokenResponse, + cookies: &CookieJar<'_>, +) -> Result> { + // Use the token to retrieve the user's Google account information. + let user_info: GoogleUserInfo = reqwest::Client::builder() + .build() + .context("failed to build reqwest client")? + .get("https://people.googleapis.com/v1/people/me?personFields=names") + .header(AUTHORIZATION, format!("Bearer {}", token.access_token())) + .send() + .await + .context("failed to complete request")? + .json() + .await + .context("failed to deserialize response")?; + + // Set a cookie with the user's name, and redirect to the home page. + cookies.add( + Cookie::build(("access_token", create_user_token(user_info.into()).unwrap())) + .same_site(SameSite::Lax) + .build(), + ); + + Ok(Redirect::to("/")) +} +pub fn google_fairing() -> impl Fairing { + AdHoc::on_ignite("Google OAuth2", |rocket| async { + rocket + .mount("/", rocket::routes![google_login, google_callback]) + .attach(OAuth2::::fairing("google")) + }) +} diff --git a/src/auth/jwts.rs b/src/auth/jwts.rs new file mode 100644 index 0000000..43efb99 --- /dev/null +++ b/src/auth/jwts.rs @@ -0,0 +1,56 @@ +use std::env; + +use chrono::{TimeDelta, Utc}; +use jsonwebtoken::{DecodingKey, EncodingKey, Header, TokenData, Validation, decode, encode}; +use serde::{Deserialize, Serialize}; +use uuid::{Builder, Uuid}; + +use crate::User; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + aud: String, + exp: usize, + iat: usize, + iss: String, + nbf: usize, + pub sub: String, + pub name: String, +} + +pub fn jwts_encode(my_claims: Claims) -> Result { + encode(&Header::default(), &my_claims, &get_encoding_key()) +} +pub fn create_user_token(user: User) -> Result { + let week_in_seconds = 3600 * 24 * 7; + let address = env::var("BASE_URL").unwrap(); + jwts_encode(Claims { + aud: address.clone(), + exp: Utc::now() + .checked_add_signed(TimeDelta::new(week_in_seconds, 0).unwrap()) + .unwrap() + .timestamp() as usize, + iat: Utc::now().timestamp() as usize, + iss: address, + nbf: Utc::now().timestamp() as usize, + sub: user.id, + name: user.username, + }) +} + +pub fn jwts_decode(token: &str) -> Result, jsonwebtoken::errors::Error> { + decode::(token, &get_decoding_key(), &Validation::default()) +} + +pub fn generate_id(namespace: &str, id: &str) -> String { + let digest = md5::compute(format!("{}-{}", namespace, id)); + let md5_bytes = digest.0; + Builder::from_md5_bytes(md5_bytes).into_uuid().to_string() +} + +fn get_encoding_key() -> EncodingKey { + EncodingKey::from_secret(env::var("JWT_SECRET").unwrap().as_ref()) +} +fn get_decoding_key() -> DecodingKey { + DecodingKey::from_secret(env::var("JWT_SECRET").unwrap().as_ref()) +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..53a30ca --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,4 @@ +pub mod email; +pub mod github; +pub mod google; +pub mod jwts; diff --git a/src/db/mod.rs b/src/db/mod.rs index 3612aae..94cc48d 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,14 +1,11 @@ use diesel::{Connection, sqlite::SqliteConnection}; -use dotenvy::dotenv; use std::env; pub mod models; pub mod schema; pub mod url; pub fn establish_connection() -> SqliteConnection { - dotenv().ok(); - let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); SqliteConnection::establish(&database_url) .unwrap_or_else(|_| panic!("Error connecting to {}", database_url)) diff --git a/src/db/models.rs b/src/db/models.rs index 6c2628a..98ca0b2 100644 --- a/src/db/models.rs +++ b/src/db/models.rs @@ -9,6 +9,7 @@ pub struct Urls { pub url: String, pub destination_url: String, pub ttl: Option, + pub owned_by: String, pub created_at: Option, pub updated_at: Option, } diff --git a/src/db/schema.rs b/src/db/schema.rs index f7b2b1d..40a6ee8 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -5,7 +5,7 @@ diesel::table! { url -> Text, destination_url -> Text, ttl -> Nullable, - owned_by -> Nullable, + owned_by -> Text, created_at -> Nullable, updated_at -> Nullable, } diff --git a/src/db/url.rs b/src/db/url.rs index dcf8907..d41ef82 100644 --- a/src/db/url.rs +++ b/src/db/url.rs @@ -11,16 +11,26 @@ pub fn get_entry(path: &str) -> Result { } pub fn upsert_entry( + username: &str, path: &str, dest: &str, time_to_live: Option, ) -> Result { let connection = &mut establish_connection(); insert_into(table) - .values((url.eq(path), destination_url.eq(dest), ttl.eq(time_to_live))) + .values(( + url.eq(path), + destination_url.eq(dest), + ttl.eq(time_to_live), + owned_by.eq(username), + )) .on_conflict(url) .do_update() - .set((destination_url.eq(dest), ttl.eq(time_to_live))) + .set(( + destination_url.eq(dest), + ttl.eq(time_to_live), + owned_by.eq(username), + )) .execute(connection) } diff --git a/src/handlers/url.rs b/src/handlers/url.rs index 15d35ed..8acbcde 100644 --- a/src/handlers/url.rs +++ b/src/handlers/url.rs @@ -10,8 +10,8 @@ pub fn handle_redirect(url: &str) -> Result { Ok(Redirect::to(row.destination_url)) } -pub fn handle_upsert(dto: UpsertUrlDto<'_>) -> (Status, &'static str) { - let row = upsert_entry(dto.url, dto.destination_url, dto.ttl); +pub fn handle_upsert(username: &str, dto: UpsertUrlDto<'_>) -> (Status, &'static str) { + let row = upsert_entry(username, dto.url, dto.destination_url, dto.ttl); if row.is_err() { return (Status::BadRequest, "Failed to upsert redirect"); } diff --git a/src/main.rs b/src/main.rs index eda1ec0..4ae6ee1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,31 +1,74 @@ +mod auth; mod db; mod dto; mod handlers; +use auth::github::github_fairing; +use auth::jwts::jwts_decode; use db::establish_connection; +use dotenvy::dotenv; use handlers::url::{handle_delete, handle_redirect, handle_upsert}; use rocket::fs::{FileServer, relative}; +use rocket::http::{Cookie, CookieJar}; +use rocket::request; use rocket::serde::json::Json; use rocket::{http::Status, response::Redirect}; - #[macro_use] extern crate rocket; +struct User { + pub id: String, + pub username: String, +} + +#[rocket::async_trait] +impl<'r> request::FromRequest<'r> for User { + type Error = (); + + async fn from_request(request: &'r request::Request<'_>) -> request::Outcome { + let cookies = request + .guard::<&CookieJar<'_>>() + .await + .expect("request cookies"); + if let Some(cookie) = cookies.get("access_token") { + let decoded = jwts_decode(cookie.value()); + if decoded.is_ok() { + let unwrapped = decoded.unwrap(); + return request::Outcome::Success(User { + username: unwrapped.claims.name, + id: unwrapped.claims.sub, + }); + } + } + + request::Outcome::Forward(Status::Unauthorized) + } +} + #[launch] fn rocket() -> _ { + dotenv().ok(); rocket::build() - .mount("/", routes![shortner,]) + .mount("/", routes![shortner, logout]) .mount("/api", routes![upsert, delete]) .mount("/", FileServer::from(relative!("web/dist"))) + .attach(github_fairing()) } + +#[get("/logout")] +fn logout(cookies: &CookieJar<'_>) -> Redirect { + cookies.remove(Cookie::from("username")); + Redirect::to("/") +} + #[get("/")] fn shortner(url: &str) -> Result { handle_redirect(url) } #[post("/write", format = "json", data = "")] -fn upsert(upsert: Json>) -> (Status, &'static str) { - handle_upsert(upsert.0) +fn upsert(user: User, upsert: Json>) -> (Status, &'static str) { + handle_upsert(&user.id.as_str(), upsert.0) } #[delete("/")]