feat: github & google login

This commit is contained in:
Elias Renman
2025-03-20 10:09:57 +01:00
parent 02a35cc555
commit 6361532c1e
23 changed files with 1664 additions and 44 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -1 +1,3 @@
DATABASE_URL=./sqlite.db
BASE_URL=https://url.renman.dev
JWT_SECRET=secret

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/target
*.db
.env
Rocket.toml

1332
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,17 @@ edition = "2024"
[dependencies]
chrono = { version = "0.4.40", features = ["serde"] }
anyhow = "1.0.97"
chrono = { version = "0.4.40", features = ["serde", "now"] }
diesel = { version = "2.2.8", features = ["sqlite", "chrono"] }
dotenvy = "0.15.7"
jsonwebtoken = "9.3.1"
md5 = "0.7.0"
reqwest = { version = "0.12.14", default-features = false, features = [
"json",
"rustls-tls",
] }
rocket = { version = "0.5.1", features = ["json"] }
rocket_oauth2 = "0.5.0"
serde = { version = "1.0.219", features = ["derive"] }
uuid = { version = "1.16.0", features = ["v3", "v5"] }

14
Rocket.toml.example Normal file
View File

@@ -0,0 +1,14 @@
[default.oauth.github]
provider = "GitHub"
client_id = "<client_id>"
client_secret = "<client_secret>"
# redirect_uri is optional for this library. If left out, it must still be set
# correctly on the provider's configuration page for the OAuth2 application.
#redirect_uri = "http://localhost:8000/auth/github"
[default.oauth.google]
provider = "Google"
client_id = "<client_id>"
client_secret = "<client_secret>"
redirect_uri = "http://localhost:8000/auth/google"

View File

@@ -1,4 +1,4 @@
-- This file should undo anything in `up.sql`
DROP TRIGGER urls_update_at_trigger;
DROP TRIGGER IF EXISTS urls_update_at_trigger;
DROP TABLE urls;

View File

@@ -3,7 +3,7 @@ CREATE TABLE urls (
url TEXT PRIMARY KEY NOT NULL,
destination_url TEXT NOT NULL,
ttl DATETIME,
owned_by TEXT,
owned_by TEXT NOT NULL CHECK (owned_by = owned_by),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE magic_links;

View File

@@ -0,0 +1,7 @@
-- Your SQL goes here
CREATE TABLE magiclinks (
url TEXT PRIMARY KEY NOT NULL,
ttl DATETIME NOT NULL,
owned_by TEXT NOT NULL CHECK (owned_by = owned_by),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
);

BIN
src/.DS_Store vendored Normal file

Binary file not shown.

BIN
src/auth/.DS_Store vendored Normal file

Binary file not shown.

48
src/auth/email.rs Normal file
View File

@@ -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<EmailUserInfo> 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<Redirect, Debug<Error>> {
// 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])
})
}

72
src/auth/github.rs Normal file
View File

@@ -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<GitHubUserInfo> 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<GitHubUserInfo>, cookies: &CookieJar<'_>) -> Redirect {
oauth2
.get_redirect(cookies, &["user:read", "user:email"])
.unwrap()
}
#[get("/auth/github")]
async fn github_callback(
token: TokenResponse<GitHubUserInfo>,
cookies: &CookieJar<'_>,
) -> Result<Redirect, Debug<Error>> {
// 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::<GitHubUserInfo>::fairing("github"))
})
}

80
src/auth/google.rs Normal file
View File

@@ -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<Value>,
resource_name: String,
}
impl From<GoogleUserInfo> 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<GoogleUserInfo>, cookies: &CookieJar<'_>) -> Redirect {
oauth2.get_redirect(cookies, &["profile"]).unwrap()
}
#[get("/auth/google")]
async fn google_callback(
token: TokenResponse<GoogleUserInfo>,
cookies: &CookieJar<'_>,
) -> Result<Redirect, Debug<Error>> {
// 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::<GoogleUserInfo>::fairing("google"))
})
}

56
src/auth/jwts.rs Normal file
View File

@@ -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<std::string::String, jsonwebtoken::errors::Error> {
encode(&Header::default(), &my_claims, &get_encoding_key())
}
pub fn create_user_token(user: User) -> Result<std::string::String, jsonwebtoken::errors::Error> {
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<TokenData<Claims>, jsonwebtoken::errors::Error> {
decode::<Claims>(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())
}

4
src/auth/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod email;
pub mod github;
pub mod google;
pub mod jwts;

View File

@@ -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))

View File

@@ -9,6 +9,7 @@ pub struct Urls {
pub url: String,
pub destination_url: String,
pub ttl: Option<NaiveDateTime>,
pub owned_by: String,
pub created_at: Option<NaiveDateTime>,
pub updated_at: Option<NaiveDateTime>,
}

View File

@@ -5,7 +5,7 @@ diesel::table! {
url -> Text,
destination_url -> Text,
ttl -> Nullable<Timestamp>,
owned_by -> Nullable<Text>,
owned_by -> Text,
created_at -> Nullable<Timestamp>,
updated_at -> Nullable<Timestamp>,
}

View File

@@ -11,16 +11,26 @@ pub fn get_entry(path: &str) -> Result<Urls, Error> {
}
pub fn upsert_entry(
username: &str,
path: &str,
dest: &str,
time_to_live: Option<NaiveDateTime>,
) -> Result<usize, Error> {
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)
}

View File

@@ -10,8 +10,8 @@ pub fn handle_redirect(url: &str) -> Result<Redirect, (Status, &'static str)> {
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");
}

View File

@@ -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<User, ()> {
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("/<url>")]
fn shortner(url: &str) -> Result<Redirect, (Status, &'static str)> {
handle_redirect(url)
}
#[post("/write", format = "json", data = "<upsert>")]
fn upsert(upsert: Json<dto::UpsertUrlDto<'_>>) -> (Status, &'static str) {
handle_upsert(upsert.0)
fn upsert(user: User, upsert: Json<dto::UpsertUrlDto<'_>>) -> (Status, &'static str) {
handle_upsert(&user.id.as_str(), upsert.0)
}
#[delete("/<url>")]