diff --git a/Cargo.lock b/Cargo.lock index 7103add62..ade450f75 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/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 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) {}; } diff --git a/pointercrate-core-pages/static/css/ui.css b/pointercrate-core-pages/static/css/ui.css index 35267dc16..64524fd11 100644 --- a/pointercrate-core-pages/static/css/ui.css +++ b/pointercrate-core-pages/static/css/ui.css @@ -282,10 +282,11 @@ textarea:invalid { text-align: left; font-style: italic; - margin: 2px 10px !important; + margin-top: 2px !important; + margin-bottom: 2px !important; } -.form-input .error { +p.error { font-size: 60% !important; color: coral; } @@ -606,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); } @@ -625,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); } @@ -644,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); } @@ -663,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); } 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-test/tests/user/login.rs b/pointercrate-test/tests/user/login.rs index fbbcf8c42..b7379bd6d 100644 --- a/pointercrate-test/tests/user/login.rs +++ b/pointercrate-test/tests/user/login.rs @@ -100,3 +100,32 @@ pub async fn test_login_no_header(pool: Pool) { .execute() .await; } + +#[sqlx::test(migrations = "../migrations")] +pub async fn test_no_login_if_google_account_linked(pool: Pool) { + let (client, mut connection) = pointercrate_test::user::setup_rocket(pool).await; + + // Make sure the user we're trying to log in to exists + let user = pointercrate_test::user::system_user_with_perms(ADMINISTRATOR, &mut *connection).await; + + client + .post("/api/v1/auth/", &()) + .header("Authorization", "Basic UGF0cmljazpiYWQgcGFzc3dvcmQ=") + .header("X-Real-IP", "127.0.0.1") + .expect_status(Status::Ok) + .execute() + .await; + + sqlx::query!("UPDATE members SET google_account_id='1' WHERE member_id=$1", user.user().id) + .execute(&mut *connection) + .await + .unwrap(); + + client + .post("/api/v1/auth/", &()) + .header("Authorization", "Basic UGF0cmljazpiYWQgcGFzc3dvcmQ=") + .header("X-Real-IP", "127.0.0.1") + .expect_status(Status::Unauthorized) + .execute() + .await; +} diff --git a/pointercrate-user-api/Cargo.toml b/pointercrate-user-api/Cargo.toml index d22906225..f91778769 100644 --- a/pointercrate-user-api/Cargo.toml +++ b/pointercrate-user-api/Cargo.toml @@ -18,5 +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"] \ No newline at end of file +legacy_accounts = ["pointercrate-user/legacy_accounts"] +oauth2 = ["pointercrate-user-pages/oauth2", "pointercrate-user/oauth2", "chrono", "reqwest"] diff --git a/pointercrate-user-api/src/lib.rs b/pointercrate-user-api/src/lib.rs index 80ce0173b..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![ @@ -23,6 +25,13 @@ 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![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 d35f70628..4cea17a87 100644 --- a/pointercrate-user-api/src/pages.rs +++ b/pointercrate-user-api/src/pages.rs @@ -1,6 +1,7 @@ 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, @@ -14,33 +15,20 @@ 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}, - auth::AuthenticatedUser, - User, - }, - rocket::serde::json::Json, +use pointercrate_user::{ + auth::legacy::{LegacyAuthenticatedUser, Registration}, + User, }; -#[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?; +#[cfg(feature = "oauth2")] +use {crate::oauth::GoogleCertificateStore, pointercrate_core::error::CoreError, pointercrate_user::auth::oauth::GoogleOauthPayload}; - 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 +46,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 +88,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) } @@ -118,3 +110,46 @@ 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, auth: Option>, 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 maybe_linked_user = AuthenticatedUser::by_validated_google_creds(&validated_credentials, &mut *pool.connection().await?).await; + + let authenticated_user = match auth { + None => maybe_linked_user?, + Some(mut signed_in_user) => { + // Unauthorized = No linked account found. But in the flow that is supposed to establish the link, + // that is exactly what we need. + if !matches!(maybe_linked_user, Err(UserError::Core(CoreError::Unauthorized))) { + return Err(CoreError::Unauthorized.into()); + } + + signed_in_user + .user + .link_google_account(&validated_credentials, &mut signed_in_user.connection) + .await?; + signed_in_user.connection.commit().await.map_err(UserError::from)?; + signed_in_user.user + }, + }; + + build_cookies(&authenticated_user, cookies)?; + + Ok(Status::NoContent) +} 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-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