mirror of
https://github.com/eliasrenman/url-shortener.git
synced 2026-03-16 20:16:06 +01:00
feat: github & google login
This commit is contained in:
@@ -1 +1,3 @@
|
||||
DATABASE_URL=./sqlite.db
|
||||
BASE_URL=https://url.renman.dev
|
||||
JWT_SECRET=secret
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
/target
|
||||
*.db
|
||||
.env
|
||||
Rocket.toml
|
||||
|
||||
1332
Cargo.lock
generated
1332
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
11
Cargo.toml
11
Cargo.toml
@@ -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
14
Rocket.toml.example
Normal 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"
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
2
migrations/2025-03-17-125909_magic_email_link/down.sql
Normal file
2
migrations/2025-03-17-125909_magic_email_link/down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
DROP TABLE magic_links;
|
||||
7
migrations/2025-03-17-125909_magic_email_link/up.sql
Normal file
7
migrations/2025-03-17-125909_magic_email_link/up.sql
Normal 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
BIN
src/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
src/auth/.DS_Store
vendored
Normal file
BIN
src/auth/.DS_Store
vendored
Normal file
Binary file not shown.
48
src/auth/email.rs
Normal file
48
src/auth/email.rs
Normal 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
72
src/auth/github.rs
Normal 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
80
src/auth/google.rs
Normal 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
56
src/auth/jwts.rs
Normal 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
4
src/auth/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod email;
|
||||
pub mod github;
|
||||
pub mod google;
|
||||
pub mod jwts;
|
||||
@@ -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))
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
51
src/main.rs
51
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<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>")]
|
||||
|
||||
Reference in New Issue
Block a user