From 78ac90f5e8e0d6b733f631c6c1a1b90dcf4a422b Mon Sep 17 00:00:00 2001 From: stadust <43299462+stadust@users.noreply.github.com> Date: Sat, 8 Mar 2025 09:28:59 +0000 Subject: [PATCH 01/14] add oauth2 feature to Cargo.tomls Signed-off-by: stadust <43299462+stadust@users.noreply.github.com> --- pointercrate-example/Cargo.toml | 3 +++ pointercrate-user-api/Cargo.toml | 3 ++- pointercrate-user-pages/Cargo.toml | 3 ++- pointercrate-user/Cargo.toml | 3 ++- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/pointercrate-example/Cargo.toml b/pointercrate-example/Cargo.toml index bb3467852..4214fc5e7 100644 --- a/pointercrate-example/Cargo.toml +++ b/pointercrate-example/Cargo.toml @@ -19,3 +19,6 @@ pointercrate-user = { version = "0.1.0", path = "../pointercrate-user" } pointercrate-user-api = { version = "0.1.0", path = "../pointercrate-user-api", features = ["legacy_accounts"] } pointercrate-user-pages = { version = "0.1.0", path = "../pointercrate-user-pages", features = ["legacy_accounts"] } rocket = "0.5.1" + +[features] +oauth2 = ["pointercrate-user-api/oauth2", "pointercrate-user-pages/oauth2"] \ No newline at end of file diff --git a/pointercrate-user-api/Cargo.toml b/pointercrate-user-api/Cargo.toml index d22906225..29d5198d3 100644 --- a/pointercrate-user-api/Cargo.toml +++ b/pointercrate-user-api/Cargo.toml @@ -19,4 +19,5 @@ nonzero_ext = "0.3.0" governor = "0.8.1" [features] -legacy_accounts = ["pointercrate-user/legacy_accounts"] \ No newline at end of file +legacy_accounts = ["pointercrate-user/legacy_accounts"] +oauth2 = ["pointercrate-user-pages/oauth2", "pointercrate-user/oauth2"] \ No newline at end of file diff --git a/pointercrate-user-pages/Cargo.toml b/pointercrate-user-pages/Cargo.toml index d510ca59a..7163f4a2f 100644 --- a/pointercrate-user-pages/Cargo.toml +++ b/pointercrate-user-pages/Cargo.toml @@ -15,4 +15,5 @@ async-trait = "0.1.88" sqlx = { version = "0.8", default-features = false, features = [ "runtime-tokio-native-tls", "macros", "postgres", "chrono", "migrate" ] } [features] -legacy_accounts = ["pointercrate-user/legacy_accounts"] \ No newline at end of file +legacy_accounts = ["pointercrate-user/legacy_accounts"] +oauth2 = ["pointercrate-user/oauth2"] \ No newline at end of file diff --git a/pointercrate-user/Cargo.toml b/pointercrate-user/Cargo.toml index 498d3710e..a6afcbc97 100644 --- a/pointercrate-user/Cargo.toml +++ b/pointercrate-user/Cargo.toml @@ -18,4 +18,5 @@ bcrypt = "0.17.0" url = "2.5.4" [features] -legacy_accounts = [] \ No newline at end of file +legacy_accounts = [] +oauth2 = [] \ No newline at end of file From 3eabae47c10eeea89db4153bea15eb77ae346cc7 Mon Sep 17 00:00:00 2001 From: stadust <43299462+stadust@users.noreply.github.com> Date: Sat, 8 Mar 2025 09:33:43 +0000 Subject: [PATCH 02/14] core-pages: add support for async scripts Signed-off-by: stadust <43299462+stadust@users.noreply.github.com> --- pointercrate-core-pages/src/head.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pointercrate-core-pages/src/head.rs b/pointercrate-core-pages/src/head.rs index 70244e459..77449f043 100644 --- a/pointercrate-core-pages/src/head.rs +++ b/pointercrate-core-pages/src/head.rs @@ -66,6 +66,10 @@ pub trait HeadLike: Sized { self.with_script(Script::new(src)) } + fn async_script(self, src: impl Into) -> Self { + self.with_script(Script::r#async(src)) + } + fn module(self, module: impl Into) -> Self { self.with_script(Script::module(module)) } @@ -85,6 +89,7 @@ impl HeadLike for Head { pub struct Script { src: String, module: bool, + r#async: bool, } impl Script { @@ -92,6 +97,15 @@ impl Script { Script { src: src.into(), module: false, + r#async: false, + } + } + + pub fn r#async>(src: S) -> Self { + Script { + src: src.into(), + module: false, + r#async: true, } } @@ -99,6 +113,7 @@ impl Script { Script { src: src.into(), module: true, + r#async: false, } } } @@ -109,6 +124,9 @@ impl Render for Script { @if self.module { script src = (self.src) type = "module" {} } + @else if self.r#async { + script src = (self.src) async {}; + } @else { script src = (self.src) {}; } From 30b10180bda7af7e41e03a81db6ef2c1b7e10773 Mon Sep 17 00:00:00 2001 From: stadust <43299462+stadust@users.noreply.github.com> Date: Sat, 8 Mar 2025 13:10:36 +0000 Subject: [PATCH 03/14] ui: drop forced left/right margin on form input contents If these are wanted, they can be applied on the form-input itself, since all form inputs we are using are laid out top-to-bottom anyway. Signed-off-by: stadust <43299462+stadust@users.noreply.github.com> --- pointercrate-core-pages/static/css/ui.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pointercrate-core-pages/static/css/ui.css b/pointercrate-core-pages/static/css/ui.css index 35267dc16..c2e1fa99b 100644 --- a/pointercrate-core-pages/static/css/ui.css +++ b/pointercrate-core-pages/static/css/ui.css @@ -282,7 +282,8 @@ textarea:invalid { text-align: left; font-style: italic; - margin: 2px 10px !important; + margin-top: 2px !important; + margin-bottom: 2px !important; } .form-input .error { From c2e27ce09f7d8541512b9a3cb195ebbf9cc3689c Mon Sep 17 00:00:00 2001 From: stadust <43299462+stadust@users.noreply.github.com> Date: Sat, 8 Mar 2025 13:11:31 +0000 Subject: [PATCH 04/14] ui: drop margin on left/right/under/over-lined classes Signed-off-by: stadust <43299462+stadust@users.noreply.github.com> --- pointercrate-core-pages/static/css/ui.css | 44 ----------------------- 1 file changed, 44 deletions(-) diff --git a/pointercrate-core-pages/static/css/ui.css b/pointercrate-core-pages/static/css/ui.css index c2e1fa99b..419dbe1bd 100644 --- a/pointercrate-core-pages/static/css/ui.css +++ b/pointercrate-core-pages/static/css/ui.css @@ -607,17 +607,6 @@ h2 .dropdown-menu ul { /* (Under)lines */ .underlined { - margin-bottom: 5px; - - /*border-bottom: 1px solid transparent; - border-image: linear-gradient( - to right, - rgba(0, 0, 0, 0) 0%, - rgba(211, 211, 211, 1) 15%, - rgba(211, 211, 211, 1) 85%, - rgba(0, 0, 0, 0) 100% - ) - 1;*/ border-bottom: 1px solid rgba(211, 211, 211, 1); } @@ -626,17 +615,6 @@ h2 .dropdown-menu ul { } .leftlined { - margin-left: 5px; - - /*border-left: 1px solid transparent; - border-image: linear-gradient( - to bottom, - rgba(0, 0, 0, 0) 0%, - rgba(211, 211, 211, 1) 15%, - rgba(211, 211, 211, 1) 85%, - rgba(0, 0, 0, 0) 100% - ) - 1;*/ border-left: 1px solid rgba(211, 211, 211, 1); } @@ -645,17 +623,6 @@ h2 .dropdown-menu ul { } .rightlined { - margin-right: 5px; - - /*border-right: 1px solid transparent; - border-image: linear-gradient( - to bottom, - rgba(0, 0, 0, 0) 0%, - rgba(211, 211, 211, 1) 15%, - rgba(211, 211, 211, 1) 85%, - rgba(0, 0, 0, 0) 100% - ) - 1;*/ border-right: 1px solid rgba(211, 211, 211, 1); } @@ -664,17 +631,6 @@ h2 .dropdown-menu ul { } .overlined { - margin-top: 5px; - - /*border-top: 1px solid transparent; - border-image: linear-gradient( - to right, - rgba(0, 0, 0, 0) 0%, - rgba(211, 211, 211, 1) 15%, - rgba(211, 211, 211, 1) 85%, - rgba(0, 0, 0, 0) 100% - ) - 1;*/ border-top: 1px solid rgba(211, 211, 211, 1); } From 9bc0a581af6ff64868443adaa466ae2f00bf848b Mon Sep 17 00:00:00 2001 From: stadust <43299462+stadust@users.noreply.github.com> Date: Sat, 8 Mar 2025 19:10:34 +0000 Subject: [PATCH 05/14] css: apply styling to p.error elements independent of parent Sometimes we might want to use these outside of forms. Signed-off-by: stadust <43299462+stadust@users.noreply.github.com> --- pointercrate-core-pages/static/css/ui.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pointercrate-core-pages/static/css/ui.css b/pointercrate-core-pages/static/css/ui.css index 419dbe1bd..64524fd11 100644 --- a/pointercrate-core-pages/static/css/ui.css +++ b/pointercrate-core-pages/static/css/ui.css @@ -286,7 +286,7 @@ textarea:invalid { margin-bottom: 2px !important; } -.form-input .error { +p.error { font-size: 60% !important; color: coral; } From b69cea3e0f9243c304546cc37ed1ba1d293c6ee6 Mon Sep 17 00:00:00 2001 From: stadust <43299462+stadust@users.noreply.github.com> Date: Sun, 9 Mar 2025 15:55:14 +0000 Subject: [PATCH 06/14] user-api: add utility function for cookie placement Both /login and /register place CSRF and access tokens in cookies. Deduplicate the code with a helper function (especially because later on, oauth will _also_ need to place the same cookies). Signed-off-by: stadust <43299462+stadust@users.noreply.github.com> --- pointercrate-user-api/src/pages.rs | 58 +++++++++++++----------------- 1 file changed, 24 insertions(+), 34 deletions(-) diff --git a/pointercrate-user-api/src/pages.rs b/pointercrate-user-api/src/pages.rs index d35f70628..9fcf9ef23 100644 --- a/pointercrate-user-api/src/pages.rs +++ b/pointercrate-user-api/src/pages.rs @@ -5,6 +5,7 @@ use pointercrate_user::{ auth::{NonMutating, PasswordOrBrowser}, error::UserError, }; +use pointercrate_user::auth::AuthenticatedUser; use pointercrate_user_pages::account::AccountPageConfig; use rocket::{ @@ -19,28 +20,13 @@ use { pointercrate_core::pool::PointercratePool, pointercrate_user::{ auth::legacy::{LegacyAuthenticatedUser, Registration}, - auth::AuthenticatedUser, User, }, rocket::serde::json::Json, }; -#[rocket::get("/login")] -pub async fn login_page(auth: Option>) -> Result { - auth.map(|_| Redirect::to(rocket::uri!(account_page))) - .ok_or_else(|| Page::new(pointercrate_user_pages::login::login_page())) -} - -// Doing the post with cookies already set will just refresh them. No point in doing that, but also not harmful. -#[rocket::post("/login")] -pub async fn login( - auth: Result, UserError>, ip: IpAddr, ratelimits: &State, cookies: &CookieJar<'_>, -) -> pointercrate_core_api::error::Result { - ratelimits.login_attempts(ip)?; - - let auth = auth?; - - let (access_token, csrf_token) = auth.user.generate_token_pair()?; +fn build_cookies(user: &AuthenticatedUser, cookies: &CookieJar<'_>) -> pointercrate_user::error::Result<()> { + let (access_token, csrf_token) = user.generate_token_pair()?; let cookie = Cookie::build(("access_token", access_token)) .http_only(true) @@ -58,6 +44,26 @@ pub async fn login( cookies.add(cookie); + Ok(()) +} + +#[rocket::get("/login")] +pub async fn login_page(auth: Option>) -> Result { + auth.map(|_| Redirect::to(rocket::uri!(account_page))) + .ok_or_else(|| Page::new(pointercrate_user_pages::login::login_page())) +} + +// Doing the post with cookies already set will just refresh them. No point in doing that, but also not harmful. +#[rocket::post("/login")] +pub async fn login( + auth: Result, UserError>, ip: IpAddr, ratelimits: &State, cookies: &CookieJar<'_>, +) -> pointercrate_core_api::error::Result { + ratelimits.login_attempts(ip)?; + + let auth = auth?; + + build_cookies(&auth.user, cookies)?; + Ok(Status::NoContent) } @@ -80,23 +86,7 @@ pub async fn register( connection.commit().await.map_err(UserError::from)?; - let (access_token, csrf_token) = user.generate_token_pair()?; - - let cookie = Cookie::build(("access_token", access_token)) - .http_only(true) - .same_site(SameSite::Strict) - .secure(!cfg!(debug_assertions)) - .path("/"); - - cookies.add(cookie); - - let cookie = Cookie::build(("csrf_token", csrf_token)) - .http_only(false) - .same_site(SameSite::Strict) - .secure(!cfg!(debug_assertions)) - .path("/"); - - cookies.add(cookie); + build_cookies(&user, cookies)?; Ok(Status::Created) } From 72850441ed738dce7649bfc38dde640868053d90 Mon Sep 17 00:00:00 2001 From: stadust <43299462+stadust@users.noreply.github.com> Date: Sat, 8 Mar 2025 13:11:56 +0000 Subject: [PATCH 07/14] user-pages: add "sign in with google" button to login page Also add a dummy request handler to the backend which for now always returns 403 UNAUTHORIZED. We use the javascript callback version (instead of redirect) of the google oauth flow so that it's easier to display potential error messages directly on the login page, as well as to avoid the form data payload that google would give us if we let it directly POST to our servers (going through javascript allows us to convert to JSON, which is easier to deal with). Signed-off-by: stadust <43299462+stadust@users.noreply.github.com> --- pointercrate-user-api/src/endpoints/auth.rs | 10 +++++- pointercrate-user-api/src/lib.rs | 2 ++ pointercrate-user-pages/src/login.rs | 32 +++++++++++++++++--- pointercrate-user-pages/static/css/login.css | 28 ++++++++++++++++- pointercrate-user-pages/static/js/login.js | 15 ++++++++- pointercrate-user/src/auth/mod.rs | 1 + pointercrate-user/src/auth/oauth/mod.rs | 4 +++ pointercrate-user/src/auth/oauth/post.rs | 8 +++++ pointercrate-user/src/config.rs | 4 +++ 9 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 pointercrate-user/src/auth/oauth/mod.rs create mode 100644 pointercrate-user/src/auth/oauth/post.rs diff --git a/pointercrate-user-api/src/endpoints/auth.rs b/pointercrate-user-api/src/endpoints/auth.rs index 4774ebdb1..df0eede5b 100644 --- a/pointercrate-user-api/src/endpoints/auth.rs +++ b/pointercrate-user-api/src/endpoints/auth.rs @@ -11,7 +11,7 @@ use pointercrate_user::{ User, }; use rocket::{ - http::Status, + http::{CookieJar, Status}, serde::json::{serde_json, Json}, State, }; @@ -105,3 +105,11 @@ pub async fn delete_me(mut auth: Auth, pred: Precondition) -> Ok(Status::NoContent) } + +#[cfg(feature = "oauth2")] +#[rocket::post("/oauth/google", data = "")] +pub async fn login_redirect_uri(payload: Json, cookies: &CookieJar<'_>) -> Result<()> { + use pointercrate_core::error::CoreError; + + Err(CoreError::Unauthorized.into()) +} diff --git a/pointercrate-user-api/src/lib.rs b/pointercrate-user-api/src/lib.rs index 80ce0173b..982ce2067 100644 --- a/pointercrate-user-api/src/lib.rs +++ b/pointercrate-user-api/src/lib.rs @@ -23,6 +23,8 @@ pub fn setup(rocket: Rocket) -> Rocket { auth_routes.extend(rocket::routes![endpoints::auth::register]); #[cfg(feature = "legacy_accounts")] page_routes.extend(rocket::routes![pages::register]); + #[cfg(feature = "oauth2")] + auth_routes.extend(rocket::routes![endpoints::auth::login_redirect_uri]); rocket .manage(ratelimits) diff --git a/pointercrate-user-pages/src/login.rs b/pointercrate-user-pages/src/login.rs index eeb8f85ca..25f2c5172 100644 --- a/pointercrate-user-pages/src/login.rs +++ b/pointercrate-user-pages/src/login.rs @@ -1,8 +1,9 @@ use maud::{html, Markup}; use pointercrate_core_pages::{head::HeadLike, PageFragment}; +use pointercrate_user::config; pub fn login_page() -> PageFragment { - PageFragment::new( + let mut frag = PageFragment::new( "Pointercrate - Login", "Log in to an existing pointercrate account or register for a new one!", ) @@ -10,7 +11,13 @@ pub fn login_page() -> PageFragment { .module("/static/core/js/modules/form.js") .module("/static/core/js/modules/tab.js") .stylesheet("/static/user/css/login.css") - .body(login_page_body()) + .body(login_page_body()); + + if cfg!(feature = "oauth2") { + frag = frag.async_script("https://accounts.google.com/gsi/client"); + } + + frag } fn login_page_body() -> Markup { @@ -22,6 +29,23 @@ fn login_page_body() -> Markup { "Sign In" } + @if cfg!(feature = "oauth2") { + p { + "If you have linked your pointercrate account with a Google account, you must sign in via Google oauth by clicking the button below:" + } + div #g_id_onload + data-ux_mode="popup" + data-auto_select="true" + data-itp_support="true" + data-client_id=(config::google_client_id()) + data-callback="googleOauthCallback" {} + + div .g_id_signin data-text="continue_with" style="margin: 10px 0px" {} + p.error #g-signin-error style="text-align: left" {} + + p.or style="text-size: small; margin: 0px" {"otherwise"} + } + p { "Sign in using your username and password. Sign in attempts are limited to 3 per 30 minutes." } @@ -38,7 +62,7 @@ fn login_page_body() -> Markup { input required = "" type = "password" name = "password" minlength = "10"; p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value="Sign In"; + input.button.blue.hover type = "submit" style = "margin-top: 15px" value="Sign In"; } } p style = "text-align: center; padding: 0px 10px" { @@ -72,7 +96,7 @@ fn login_page_body() -> Markup { input required = "" type = "password" name = "password2" minlength = "10"; p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = "Sign Up"; + input.button.blue.hover type = "submit" style = "margin-top: 15px" value = "Sign Up"; } } } diff --git a/pointercrate-user-pages/static/css/login.css b/pointercrate-user-pages/static/css/login.css index 3d3715d74..a5c694404 100644 --- a/pointercrate-user-pages/static/css/login.css +++ b/pointercrate-user-pages/static/css/login.css @@ -1,4 +1,4 @@ -#login-tabber p :not(.error){ +#login-tabber p:not(.error) { font-size: x-small; text-align: center; } @@ -10,4 +10,30 @@ #login-tabber { margin-bottom: 20px; +} + +p.or { + overflow: hidden; + text-align: center; +} + +p.or::before, +p.or::after { + background-color: rgba(211, 211, 211, 1); + content: ""; + display: inline-block; + height: 1px; + position: relative; + vertical-align: middle; + width: 50%; +} + +p.or::before { + right: 0.5em; + margin-left: -50%; +} + +p.or::after { + left: 0.5em; + margin-right: -50%; } \ No newline at end of file diff --git a/pointercrate-user-pages/static/js/login.js b/pointercrate-user-pages/static/js/login.js index 273caa891..d8bdcc3ab 100644 --- a/pointercrate-user-pages/static/js/login.js +++ b/pointercrate-user-pages/static/js/login.js @@ -89,8 +89,21 @@ function intializeRegisterForm() { }); } +function googleOauthCallback (response) { + let error = document.getElementById("g-signin-error"); + + post("/api/v1/auth/oauth/google", {}, response) + .then(() => window.location = "/account/") + .catch(response => { + error.innerText = response.data.message; + error.style.display = "block"; + }); +} + +window.googleOauthCallback = googleOauthCallback; + $(document).ready(function () { new TabbedPane(document.getElementById("login-tabber"), null); initializeLoginForm(); intializeRegisterForm(); -}); +}); \ No newline at end of file diff --git a/pointercrate-user/src/auth/mod.rs b/pointercrate-user/src/auth/mod.rs index 4577d530a..48f2d956b 100644 --- a/pointercrate-user/src/auth/mod.rs +++ b/pointercrate-user/src/auth/mod.rs @@ -30,6 +30,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; mod delete; mod get; pub mod legacy; +pub mod oauth; mod patch; mod post; diff --git a/pointercrate-user/src/auth/oauth/mod.rs b/pointercrate-user/src/auth/oauth/mod.rs new file mode 100644 index 000000000..eb8e0dc9c --- /dev/null +++ b/pointercrate-user/src/auth/oauth/mod.rs @@ -0,0 +1,4 @@ +mod post; + +#[cfg(feature = "oauth2")] +pub use post::GoogleOauthPayload; \ No newline at end of file diff --git a/pointercrate-user/src/auth/oauth/post.rs b/pointercrate-user/src/auth/oauth/post.rs new file mode 100644 index 000000000..d5530952f --- /dev/null +++ b/pointercrate-user/src/auth/oauth/post.rs @@ -0,0 +1,8 @@ +use serde::Deserialize; + + +#[cfg(feature = "oauth2")] +#[derive(Debug, Deserialize)] +pub struct GoogleOauthPayload { + credential: String, +} \ No newline at end of file diff --git a/pointercrate-user/src/config.rs b/pointercrate-user/src/config.rs index 3d55875e5..99749989c 100644 --- a/pointercrate-user/src/config.rs +++ b/pointercrate-user/src/config.rs @@ -19,3 +19,7 @@ pub(crate) fn secret() -> Vec { Err(err) => panic!("Unable to open secret file: {:?}", err), } } + +pub fn google_client_id() -> String { + std::env::var("GOOGLE_CLIENT_ID").expect("GOOGLE_CLIENT_ID is not set") +} \ No newline at end of file From cd2e16b6e52bed49504d3ea358dd44e5d3e09bd0 Mon Sep 17 00:00:00 2001 From: stadust <43299462+stadust@users.noreply.github.com> Date: Sat, 8 Mar 2025 21:49:12 +0000 Subject: [PATCH 08/14] add google account id to members table Mark as UNIQUE to prevent any bugs/race conditions from allowing the same google account for multiple pointercrate accounts. Signed-off-by: stadust <43299462+stadust@users.noreply.github.com> --- migrations/20250308214833_google_account_id.down.sql | 2 ++ migrations/20250308214833_google_account_id.up.sql | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 migrations/20250308214833_google_account_id.down.sql create mode 100644 migrations/20250308214833_google_account_id.up.sql diff --git a/migrations/20250308214833_google_account_id.down.sql b/migrations/20250308214833_google_account_id.down.sql new file mode 100644 index 000000000..a64be7a49 --- /dev/null +++ b/migrations/20250308214833_google_account_id.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +ALTER TABLE members DROP COLUMN google_account_id; \ No newline at end of file diff --git a/migrations/20250308214833_google_account_id.up.sql b/migrations/20250308214833_google_account_id.up.sql new file mode 100644 index 000000000..91346f35b --- /dev/null +++ b/migrations/20250308214833_google_account_id.up.sql @@ -0,0 +1,2 @@ +-- Add up migration script here +ALTER TABLE members ADD COLUMN google_account_id VARCHAR(256) UNIQUE; \ No newline at end of file From 704211183ef007efd8ff47731b99e24c65c0d55b Mon Sep 17 00:00:00 2001 From: stadust <43299462+stadust@users.noreply.github.com> Date: Sun, 9 Mar 2025 13:55:57 +0000 Subject: [PATCH 09/14] Handle members with linked google accounts in auth module Introduce a new `AuthenticationType` for these, so that password based login will automatically be rejected. Signed-off-by: stadust <43299462+stadust@users.noreply.github.com> --- pointercrate-user/src/auth/get.rs | 42 +++++++++++++++++------- pointercrate-user/src/auth/mod.rs | 3 ++ pointercrate-user/src/auth/oauth/mod.rs | 26 ++++++++++++++- pointercrate-user/src/auth/oauth/post.rs | 3 +- pointercrate-user/src/config.rs | 2 +- 5 files changed, 60 insertions(+), 16 deletions(-) diff --git a/pointercrate-user/src/auth/get.rs b/pointercrate-user/src/auth/get.rs index 8e820acf8..803360314 100644 --- a/pointercrate-user/src/auth/get.rs +++ b/pointercrate-user/src/auth/get.rs @@ -11,7 +11,7 @@ use super::NoAuth; impl AuthenticatedUser { pub async fn by_id(id: i32, connection: &mut PgConnection) -> Result { let row = sqlx::query!( - r#"SELECT member_id, members.name, permissions::integer, display_name, youtube_channel::text, password_hash, generation FROM members WHERE member_id = $1"#, + r#"SELECT member_id, members.name, permissions::integer, display_name, youtube_channel::text, password_hash, generation, google_account_id FROM members WHERE member_id = $1"#, id ) .fetch_one(connection) @@ -20,17 +20,26 @@ impl AuthenticatedUser { match row { Err(Error::RowNotFound) => Err(CoreError::Unauthorized.into()), Err(err) => Err(err.into()), - Ok(row) => Ok(AuthenticatedUser { - gen: row.generation, - auth_type: AuthenticationType::legacy(construct_from_row!(row), row.password_hash), - auth_artifact: NoAuth, - }), + Ok(row) => { + let user = construct_from_row!(row); + + let auth_type = match row.google_account_id { + Some(_) => AuthenticationType::oauth(user), + None => AuthenticationType::legacy(user, row.password_hash), + }; + + Ok(AuthenticatedUser { + gen: row.generation, + auth_type, + auth_artifact: NoAuth, + }) + }, } } pub async fn by_name(name: &str, connection: &mut PgConnection) -> Result { let row = sqlx::query!( - r#"SELECT member_id, members.name, permissions::integer, display_name, youtube_channel::text, password_hash, generation FROM members WHERE members.name = $1"#, + r#"SELECT member_id, members.name, permissions::integer, display_name, youtube_channel::text, password_hash, generation, google_account_id FROM members WHERE members.name = $1"#, name.to_string() ) .fetch_one(connection) @@ -39,11 +48,20 @@ impl AuthenticatedUser { match row { Err(Error::RowNotFound) => Err(CoreError::Unauthorized.into()), Err(err) => Err(err.into()), - Ok(row) => Ok(AuthenticatedUser { - gen: row.generation, - auth_type: AuthenticationType::legacy(construct_from_row!(row), row.password_hash), - auth_artifact: NoAuth, - }), + Ok(row) => { + let user = construct_from_row!(row); + + let auth_type = match row.google_account_id { + Some(_) => AuthenticationType::oauth(user), + None => AuthenticationType::legacy(user, row.password_hash), + }; + + Ok(AuthenticatedUser { + gen: row.generation, + auth_type, + auth_artifact: NoAuth, + }) + }, } } } diff --git a/pointercrate-user/src/auth/mod.rs b/pointercrate-user/src/auth/mod.rs index 48f2d956b..0d9cda328 100644 --- a/pointercrate-user/src/auth/mod.rs +++ b/pointercrate-user/src/auth/mod.rs @@ -79,6 +79,7 @@ pub struct AuthenticatedUser { pub enum AuthenticationType { Legacy(LegacyAuthenticatedUser), + Oauth2(oauth::OA2AuthenticatedUser), } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -148,12 +149,14 @@ impl AuthenticatedUser { pub fn into_user(self) -> User { match self.auth_type { AuthenticationType::Legacy(legacy) => legacy.into_user(), + AuthenticationType::Oauth2(oauth) => oauth.into_user(), } } pub fn user(&self) -> &User { match &self.auth_type { AuthenticationType::Legacy(legacy) => legacy.user(), + AuthenticationType::Oauth2(oauth) => oauth.user(), } } diff --git a/pointercrate-user/src/auth/oauth/mod.rs b/pointercrate-user/src/auth/oauth/mod.rs index eb8e0dc9c..34cec20b8 100644 --- a/pointercrate-user/src/auth/oauth/mod.rs +++ b/pointercrate-user/src/auth/oauth/mod.rs @@ -1,4 +1,28 @@ mod post; #[cfg(feature = "oauth2")] -pub use post::GoogleOauthPayload; \ No newline at end of file +pub use post::GoogleOauthPayload; + +use crate::User; + +use super::AuthenticationType; + +pub struct OA2AuthenticatedUser { + user: User, +} + +impl OA2AuthenticatedUser { + pub fn into_user(self) -> User { + self.user + } + + pub fn user(&self) -> &User { + &self.user + } +} + +impl AuthenticationType { + pub fn oauth(user: User) -> AuthenticationType { + AuthenticationType::Oauth2(OA2AuthenticatedUser { user }) + } +} diff --git a/pointercrate-user/src/auth/oauth/post.rs b/pointercrate-user/src/auth/oauth/post.rs index d5530952f..0fbac3efc 100644 --- a/pointercrate-user/src/auth/oauth/post.rs +++ b/pointercrate-user/src/auth/oauth/post.rs @@ -1,8 +1,7 @@ use serde::Deserialize; - #[cfg(feature = "oauth2")] #[derive(Debug, Deserialize)] pub struct GoogleOauthPayload { credential: String, -} \ No newline at end of file +} diff --git a/pointercrate-user/src/config.rs b/pointercrate-user/src/config.rs index 99749989c..092f90fa8 100644 --- a/pointercrate-user/src/config.rs +++ b/pointercrate-user/src/config.rs @@ -22,4 +22,4 @@ pub(crate) fn secret() -> Vec { pub fn google_client_id() -> String { std::env::var("GOOGLE_CLIENT_ID").expect("GOOGLE_CLIENT_ID is not set") -} \ No newline at end of file +} From ddaeec9b7395a92507e593a47566335cd14295c4 Mon Sep 17 00:00:00 2001 From: stadust <43299462+stadust@users.noreply.github.com> Date: Sun, 9 Mar 2025 15:50:47 +0000 Subject: [PATCH 10/14] Implement "sign in with google" flow Have the backend validate credentials it receives via /auth/oauth/google with the certificates that google publishes, decode the associated JWT to determine the google account id, and then log in the user who has that google account id linked by placing the required cookies. Signed-off-by: stadust <43299462+stadust@users.noreply.github.com> --- Cargo.lock | 4 + Cargo.toml | 2 +- pointercrate-user-api/Cargo.toml | 6 +- pointercrate-user-api/src/endpoints/auth.rs | 10 +-- pointercrate-user-api/src/lib.rs | 11 ++- pointercrate-user-api/src/oauth.rs | 83 +++++++++++++++++++++ pointercrate-user-api/src/pages.rs | 43 +++++++++-- pointercrate-user/Cargo.toml | 3 +- pointercrate-user/src/auth/oauth/get.rs | 29 +++++++ pointercrate-user/src/auth/oauth/mod.rs | 5 +- pointercrate-user/src/auth/oauth/post.rs | 81 +++++++++++++++++++- 11 files changed, 252 insertions(+), 25 deletions(-) create mode 100644 pointercrate-user-api/src/oauth.rs create mode 100644 pointercrate-user/src/auth/oauth/get.rs diff --git a/Cargo.lock b/Cargo.lock index e6f9ff21b..f9f6793e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1985,12 +1985,14 @@ name = "pointercrate-user" version = "0.1.0" dependencies = [ "bcrypt", + "chrono", "derive_more", "futures", "jsonwebtoken", "log", "pointercrate-core", "serde", + "serde_json", "sqlx", "url", ] @@ -2000,6 +2002,7 @@ name = "pointercrate-user-api" version = "0.1.0" dependencies = [ "base64 0.22.1", + "chrono", "governor", "log", "nonzero_ext", @@ -2007,6 +2010,7 @@ dependencies = [ "pointercrate-core-api", "pointercrate-user", "pointercrate-user-pages", + "reqwest", "rocket", "sqlx", ] diff --git a/Cargo.toml b/Cargo.toml index 36cae776e..d3392d49d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,4 +24,4 @@ authors = ["stadust"] description = "Libraries for creating pointercrate-like demonlist websites" homepage = "https://pointercrate.com" edition = "2021" -repository = "https://github.com/stadust/pointercrate" \ No newline at end of file +repository = "https://github.com/stadust/pointercrate" diff --git a/pointercrate-user-api/Cargo.toml b/pointercrate-user-api/Cargo.toml index 29d5198d3..f91778769 100644 --- a/pointercrate-user-api/Cargo.toml +++ b/pointercrate-user-api/Cargo.toml @@ -18,6 +18,10 @@ base64 = "0.22.1" nonzero_ext = "0.3.0" governor = "0.8.1" +# Dependencies needed only for oauth2 +reqwest = { version = "0.12.12", optional = true, features = ["json"] } +chrono = { version = "0.4.40", optional = true } + [features] legacy_accounts = ["pointercrate-user/legacy_accounts"] -oauth2 = ["pointercrate-user-pages/oauth2", "pointercrate-user/oauth2"] \ No newline at end of file +oauth2 = ["pointercrate-user-pages/oauth2", "pointercrate-user/oauth2", "chrono", "reqwest"] diff --git a/pointercrate-user-api/src/endpoints/auth.rs b/pointercrate-user-api/src/endpoints/auth.rs index df0eede5b..4774ebdb1 100644 --- a/pointercrate-user-api/src/endpoints/auth.rs +++ b/pointercrate-user-api/src/endpoints/auth.rs @@ -11,7 +11,7 @@ use pointercrate_user::{ User, }; use rocket::{ - http::{CookieJar, Status}, + http::Status, serde::json::{serde_json, Json}, State, }; @@ -105,11 +105,3 @@ pub async fn delete_me(mut auth: Auth, pred: Precondition) -> Ok(Status::NoContent) } - -#[cfg(feature = "oauth2")] -#[rocket::post("/oauth/google", data = "")] -pub async fn login_redirect_uri(payload: Json, cookies: &CookieJar<'_>) -> Result<()> { - use pointercrate_core::error::CoreError; - - Err(CoreError::Unauthorized.into()) -} diff --git a/pointercrate-user-api/src/lib.rs b/pointercrate-user-api/src/lib.rs index 982ce2067..9238acae6 100644 --- a/pointercrate-user-api/src/lib.rs +++ b/pointercrate-user-api/src/lib.rs @@ -4,11 +4,13 @@ use rocket::{Build, Rocket}; pub mod auth; mod endpoints; +#[cfg(feature = "oauth2")] +mod oauth; mod pages; mod ratelimits; #[allow(unused_mut)] -pub fn setup(rocket: Rocket) -> Rocket { +pub fn setup(mut rocket: Rocket) -> Rocket { let ratelimits = UserRatelimits::new(); let mut auth_routes = rocket::routes![ @@ -24,7 +26,12 @@ pub fn setup(rocket: Rocket) -> Rocket { #[cfg(feature = "legacy_accounts")] page_routes.extend(rocket::routes![pages::register]); #[cfg(feature = "oauth2")] - auth_routes.extend(rocket::routes![endpoints::auth::login_redirect_uri]); + auth_routes.extend(rocket::routes![pages::google_oauth_login]); + + #[cfg(feature = "oauth2")] + { + rocket = rocket.manage(oauth::GoogleCertificateStore::default()); + } rocket .manage(ratelimits) diff --git a/pointercrate-user-api/src/oauth.rs b/pointercrate-user-api/src/oauth.rs new file mode 100644 index 000000000..d447d20b0 --- /dev/null +++ b/pointercrate-user-api/src/oauth.rs @@ -0,0 +1,83 @@ +use std::sync::Arc; + +use chrono::{Duration, Utc}; +use pointercrate_user::auth::oauth::{GoogleCertificateDatabase, ValidatedGoogleCredentials}; +use reqwest::Client; +use rocket::tokio::sync::RwLock; + +pub const GOOGLE_CERT_ENDPOINT: &'static str = "https://www.googleapis.com/oauth2/v3/certs"; + +#[derive(Default)] +pub struct GoogleCertificateStore { + db: Arc>, +} + +#[allow(dead_code)] /* fields exist specifically for Debug output */ +#[derive(Debug)] +pub enum CertificateRefreshError { + Reqwest(reqwest::Error), + MalformedCacheControlHeader(String), + MissingCacheControlHeader, +} + +impl From for CertificateRefreshError { + fn from(value: reqwest::Error) -> Self { + CertificateRefreshError::Reqwest(value) + } +} + +impl GoogleCertificateStore { + pub async fn validate_credentials(&self, creds: &str) -> Option { + self.db.read().await.validate_credentials(creds) + } + + pub async fn needs_refresh(&self) -> bool { + self.db.read().await.needs_refresh() + } + + pub async fn refresh(&self) -> Result<(), CertificateRefreshError> { + let mut guard = self.db.write().await; + + // Between calling needs_refresh and us taking the write lock, did the need + // for a refresh potentially go away (e.g. because some other task did the refresh in the meantime)? + if !guard.needs_refresh() { + return Ok(()); + } + + let client = Client::new(); + + let request_time = Utc::now(); + let response = client.get(GOOGLE_CERT_ENDPOINT).send().await?; + + let cc_header = response + .headers() + .get("Cache-Control") + .ok_or(CertificateRefreshError::MissingCacheControlHeader)?; + let cc_header = cc_header + .to_str() + .map_err(|_| CertificateRefreshError::MalformedCacheControlHeader("unparsable".to_string()))?; + + let max_age_directive = cc_header + .split(',') + .find(|directive| directive.trim().starts_with("max-age")) + .ok_or_else(|| CertificateRefreshError::MalformedCacheControlHeader(cc_header.to_string()))?; + + let mut parts = max_age_directive.split('='); + _ = parts.next(); /* Split<'_, T> implements FusedIterator, so can ignore None handling here */ + let age_str = parts + .next() + .ok_or_else(|| CertificateRefreshError::MalformedCacheControlHeader(max_age_directive.to_string()))?; + let age = age_str + .parse() + .map_err(|_| CertificateRefreshError::MalformedCacheControlHeader(age_str.to_string()))?; + + let expiry = request_time + Duration::seconds(age); + + let mut new_db = response.json::().await?; + new_db.expiry = Some(expiry); + + *guard = new_db; + + Ok(()) + } +} diff --git a/pointercrate-user-api/src/pages.rs b/pointercrate-user-api/src/pages.rs index 9fcf9ef23..e64bbe0ed 100644 --- a/pointercrate-user-api/src/pages.rs +++ b/pointercrate-user-api/src/pages.rs @@ -1,11 +1,11 @@ use crate::{auth::Auth, ratelimits::UserRatelimits}; use pointercrate_core::permission::PermissionsManager; use pointercrate_core_api::response::Page; +use pointercrate_user::auth::AuthenticatedUser; use pointercrate_user::{ auth::{NonMutating, PasswordOrBrowser}, error::UserError, }; -use pointercrate_user::auth::AuthenticatedUser; use pointercrate_user_pages::account::AccountPageConfig; use rocket::{ @@ -15,16 +15,18 @@ use rocket::{ }; use std::net::IpAddr; +#[cfg(any(feature = "legacy_accounts", feature = "oauth2"))] +use {pointercrate_core::pool::PointercratePool, rocket::serde::json::Json}; + #[cfg(feature = "legacy_accounts")] -use { - pointercrate_core::pool::PointercratePool, - pointercrate_user::{ - auth::legacy::{LegacyAuthenticatedUser, Registration}, - User, - }, - rocket::serde::json::Json, +use pointercrate_user::{ + auth::legacy::{LegacyAuthenticatedUser, Registration}, + User, }; +#[cfg(feature = "oauth2")] +use {crate::oauth::GoogleCertificateStore, pointercrate_core::error::CoreError, pointercrate_user::auth::oauth::GoogleOauthPayload}; + fn build_cookies(user: &AuthenticatedUser, cookies: &CookieJar<'_>) -> pointercrate_user::error::Result<()> { let (access_token, csrf_token) = user.generate_token_pair()?; @@ -108,3 +110,28 @@ pub async fn logout(_auth: Auth, cookies: &CookieJar<'_>) -> Redire Redirect::to(rocket::uri!(login_page)) } + +#[cfg(feature = "oauth2")] +#[rocket::post("/oauth/google", data = "")] +pub async fn google_oauth_login( + payload: Json, key_store: &State, pool: &State, + cookies: &rocket::http::CookieJar<'_>, +) -> pointercrate_core_api::error::Result { + if key_store.needs_refresh().await { + key_store + .refresh() + .await + .map_err(|err| CoreError::internal_server_error(format!("Failed to retrieve signing certificates from Google! {:?}", err)))?; + } + + let validated_credentials = key_store + .validate_credentials(&payload.into_inner().credential) + .await + .ok_or(CoreError::Unauthorized)?; + + let authenticated_user = AuthenticatedUser::by_validated_google_creds(&validated_credentials, &mut *pool.connection().await?).await?; + + build_cookies(&authenticated_user, cookies)?; + + Ok(Status::NoContent) +} diff --git a/pointercrate-user/Cargo.toml b/pointercrate-user/Cargo.toml index a6afcbc97..8672bbc7c 100644 --- a/pointercrate-user/Cargo.toml +++ b/pointercrate-user/Cargo.toml @@ -16,7 +16,8 @@ log = "0.4.26" futures = "0.3.31" bcrypt = "0.17.0" url = "2.5.4" +chrono = { version = "0.4.40", optional = true } [features] legacy_accounts = [] -oauth2 = [] \ No newline at end of file +oauth2 = ["chrono"] diff --git a/pointercrate-user/src/auth/oauth/get.rs b/pointercrate-user/src/auth/oauth/get.rs new file mode 100644 index 000000000..9e4ec9570 --- /dev/null +++ b/pointercrate-user/src/auth/oauth/get.rs @@ -0,0 +1,29 @@ +use pointercrate_core::error::CoreError; +use sqlx::{Error, PgConnection}; + +use crate::auth::{AuthenticatedUser, AuthenticationType, PasswordOrBrowser}; +use crate::Result; +use crate::User; + +use super::ValidatedGoogleCredentials; + +impl AuthenticatedUser { + pub async fn by_validated_google_creds(creds: &ValidatedGoogleCredentials, connection: &mut PgConnection) -> Result { + let row = sqlx::query!( + r#"SELECT member_id, members.name, permissions::integer, display_name, youtube_channel::text, password_hash, generation, google_account_id FROM members WHERE google_account_id = $1"#, + creds.google_account_id() + ) + .fetch_one(connection) + .await; + + match row { + Err(Error::RowNotFound) => Err(CoreError::Unauthorized.into()), + Err(err) => Err(err.into()), + Ok(row) => Ok(AuthenticatedUser { + gen: row.generation, + auth_type: AuthenticationType::oauth(construct_from_row!(row)), + auth_artifact: PasswordOrBrowser(false), + }), + } + } +} diff --git a/pointercrate-user/src/auth/oauth/mod.rs b/pointercrate-user/src/auth/oauth/mod.rs index 34cec20b8..dfda313c6 100644 --- a/pointercrate-user/src/auth/oauth/mod.rs +++ b/pointercrate-user/src/auth/oauth/mod.rs @@ -1,7 +1,10 @@ +#[cfg(feature = "oauth2")] +mod get; +#[cfg(feature = "oauth2")] mod post; #[cfg(feature = "oauth2")] -pub use post::GoogleOauthPayload; +pub use post::{GoogleCertificateDatabase, GoogleOauthPayload, ValidatedGoogleCredentials}; use crate::User; diff --git a/pointercrate-user/src/auth/oauth/post.rs b/pointercrate-user/src/auth/oauth/post.rs index 0fbac3efc..da103e826 100644 --- a/pointercrate-user/src/auth/oauth/post.rs +++ b/pointercrate-user/src/auth/oauth/post.rs @@ -1,7 +1,84 @@ +use chrono::{DateTime, Utc}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation}; use serde::Deserialize; -#[cfg(feature = "oauth2")] +use crate::config; + #[derive(Debug, Deserialize)] pub struct GoogleOauthPayload { - credential: String, + pub credential: String, +} + +#[derive(Deserialize)] +pub struct ValidatedGoogleCredentials { + sub: String, +} + +impl ValidatedGoogleCredentials { + pub fn google_account_id(&self) -> &str { + self.sub.as_ref() + } +} + +#[derive(Deserialize)] +pub struct SigningKey { + e: String, + n: String, + kid: String, + alg: Algorithm, +} + +#[derive(Default, Deserialize)] +pub struct GoogleCertificateDatabase { + pub keys: Vec, + + #[serde(default)] + pub expiry: Option>, +} + +impl GoogleCertificateDatabase { + pub fn needs_refresh(&self) -> bool { + match self.expiry { + None => true, + Some(expiry) => Utc::now() >= expiry, + } + } + + pub fn validate_credentials(&self, creds: &str) -> Option { + let header = jsonwebtoken::decode_header(creds).ok()?; + let key = self.keys.iter().find(|key| Some(key.kid.as_ref()) == header.kid.as_deref())?; + + let mut validation = Validation::new(key.alg); + validation.set_issuer(&["accounts.google.com", "https://accounts.google.com"]); + validation.set_audience(&[config::google_client_id()]); + validation.required_spec_claims.extend(["iss".to_string(), "aud".to_string()]); + + jsonwebtoken::decode(creds, &DecodingKey::from_rsa_components(&key.n, &key.e).ok()?, &validation) + .map(|data| data.claims) + .inspect_err(|err| { + use jsonwebtoken::errors::ErrorKind::*; + + match err.kind() { + // With these, we don't run into any danger of accidentally logging credentials + InvalidToken + | InvalidSignature + | InvalidEcdsaKey + | InvalidRsaKey(_) + | RsaFailedSigning + | InvalidAlgorithmName + | InvalidKeyFormat + | MissingRequiredClaim(_) + | ExpiredSignature + | InvalidIssuer + | InvalidAudience + | InvalidSubject + | ImmatureSignature + | InvalidAlgorithm + | MissingAlgorithm => log::warn!("Failure to validate credentials allegedly received from google: {:?}", err), + // All others, better be on the safe side and not log the actual error + _ => log::warn!("Failure to parse/validate credentials allegedly received from google"), + } + }) + .ok() + } } From 525a8f73be99ec82604a7bfdb81a468e34b2fd03 Mon Sep 17 00:00:00 2001 From: stadust <43299462+stadust@users.noreply.github.com> Date: Sun, 9 Mar 2025 17:44:54 +0000 Subject: [PATCH 11/14] ui: add "Link with google" button to profile page Signed-off-by: stadust <43299462+stadust@users.noreply.github.com> --- .../src/account/profile.rs | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/pointercrate-user-pages/src/account/profile.rs b/pointercrate-user-pages/src/account/profile.rs index af8334f66..01a842010 100644 --- a/pointercrate-user-pages/src/account/profile.rs +++ b/pointercrate-user-pages/src/account/profile.rs @@ -1,7 +1,11 @@ use crate::account::AccountPageTab; use maud::{html, Markup, PreEscaped}; use pointercrate_core::permission::PermissionsManager; -use pointercrate_user::auth::{AuthenticatedUser, NonMutating}; +use pointercrate_core_pages::head::Script; +use pointercrate_user::{ + auth::{AuthenticatedUser, NonMutating}, + config, +}; use sqlx::PgConnection; pub struct ProfileTab; @@ -16,6 +20,14 @@ impl AccountPageTab for ProfileTab { "/static/user/js/account/profile.js".into() } + fn additional_scripts(&self) -> Vec