Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
.secret
.idea
.vscode
**/.DS_Store
.DS_Store

# random stuff I have because of enabling eslint in vscode
node_modules
eslint.config.mjs
package-lock.json
package.json
package.json
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions migrations/20241005093210_add_google_account_id.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Add down migration script here
-- Add down migration script here

ALTER TABLE members
DROP COLUMN google_account_id,
ADD COLUMN email_address EMAIL;
6 changes: 6 additions & 0 deletions migrations/20241005093210_add_google_account_id.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Add up migration script here
-- Add up migration script here

ALTER TABLE members
ADD COLUMN google_account_id VARCHAR(255) UNIQUE NULL,
DROP COLUMN email_address;
22 changes: 0 additions & 22 deletions pointercrate-core/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,25 +1,3 @@
use crate::util::from_env_or_default;
use log::error;
use std::{fs::File, io::Read};

pub fn database_url() -> String {
std::env::var("DATABASE_URL").expect("DATABASE_URL is not set")
}

pub fn secret() -> Vec<u8> {
let path: String = from_env_or_default("SECRET_FILE", ".secret".into());

match File::open(path) {
Ok(file) => file.bytes().collect::<Result<Vec<u8>, _>>().unwrap(),
Err(err) if cfg!(debug_assertions) => {
// needed for integration tests/CI
error!(
"Failed to read secret, using an unsecure default since this is a debug build- {:?}",
err
);

vec![0x0; 64]
},
Err(err) => panic!("Unable to open secret file: {:?}", err),
}
}
4 changes: 2 additions & 2 deletions pointercrate-example/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ pointercrate-demonlist = { version = "0.1.0", path = "../pointercrate-demonlist"
pointercrate-demonlist-api = { version = "0.1.0", path = "../pointercrate-demonlist-api" }
pointercrate-demonlist-pages = { version = "0.1.0", path = "../pointercrate-demonlist-pages" }
pointercrate-user = { version = "0.1.0", path = "../pointercrate-user" }
pointercrate-user-api = { version = "0.1.0", path = "../pointercrate-user-api" }
pointercrate-user-pages = { version = "0.1.0", path = "../pointercrate-user-pages", features = ["legacy_accounts"] }
pointercrate-user-api = { version = "0.1.0", path = "../pointercrate-user-api", features = ["legacy_accounts", "oauth2"] }
pointercrate-user-pages = { version = "0.1.0", path = "../pointercrate-user-pages", features = ["legacy_accounts", "oauth2"] }
rocket = "0.5.1"
19 changes: 11 additions & 8 deletions pointercrate-user-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,21 @@ edition.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
rocket = {version = "0.5.1", features = ["json"]}
sqlx = { version = "0.8", default-features = false, features = [ "runtime-tokio-native-tls", "macros", "postgres", "chrono", "migrate" ] }
pointercrate-user = {path = "../pointercrate-user"}
pointercrate-user-pages = {path = "../pointercrate-user-pages"}
pointercrate-core = {path = "../pointercrate-core"}
pointercrate-core-api = {path = "../pointercrate-core-api"}
pointercrate-core-pages = {path = "../pointercrate-core-pages"}
rocket = { version = "0.5.1", features = ["json"] }
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-native-tls", "macros", "postgres", "chrono", "migrate"] }
pointercrate-user = { path = "../pointercrate-user" }
pointercrate-user-pages = { path = "../pointercrate-user-pages" }
pointercrate-core = { path = "../pointercrate-core" }
pointercrate-core-api = { path = "../pointercrate-core-api" }
pointercrate-core-pages = { path = "../pointercrate-core-pages" }
log = "0.4.22"
base64 = "0.22.1"
nonzero_ext = "0.3.0"
serde_urlencoded = "0.7.0"
governor = "0.6.0"
serde = { version = "1.0.210", features = ["derive"], optional = true }
getrandom = { version = "0.2.15", optional = true }

[features]
legacy_accounts = ["pointercrate-user/legacy_accounts"]
legacy_accounts = ["pointercrate-user/legacy_accounts"]
oauth2 = ["pointercrate-user/oauth2", "serde", "getrandom"]
2 changes: 1 addition & 1 deletion pointercrate-user-api/src/endpoints/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ use rocket::{
State,
};
use std::net::IpAddr;

#[cfg(feature = "legacy_accounts")]
use {
pointercrate_core::pool::PointercratePool,
Expand Down Expand Up @@ -65,6 +64,7 @@ pub async fn login(
pub async fn invalidate(mut auth: BasicAuth) -> Result<Status> {
match auth.user {
AuthenticatedUser::Legacy(legacy) => legacy.invalidate_all_tokens(auth.secret, &mut auth.connection).await?,
AuthenticatedUser::OAuth2(oauth) => oauth.invalidate_all_tokens(&mut auth.connection).await?, // I have no clue what we'll do here
}

auth.connection.commit().await.map_err(UserError::from)?;
Expand Down
2 changes: 2 additions & 0 deletions pointercrate-user-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ pub fn setup(rocket: Rocket<Build>) -> Rocket<Build> {
auth_routes.extend(rocket::routes![endpoints::auth::register]);
#[cfg(feature = "legacy_accounts")]
page_routes.extend(rocket::routes![pages::register]);
#[cfg(feature = "oauth2")]
page_routes.extend(rocket::routes![pages::authorize, pages::callback]);

rocket
.manage(ratelimits)
Expand Down
141 changes: 138 additions & 3 deletions pointercrate-user-api/src/pages.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,30 @@
//! Endpoints that are only intended to be used from the browser
//!
//! These are not part of the pointercrate API, either because they simply serve
//! HTML content, or because they are related to browser based authentication flows
//! (they set the access_token cookie, or are oauth related).

use crate::{
auth::{BasicAuth, TokenAuth},
ratelimits::UserRatelimits,
};
use pointercrate_core::permission::PermissionsManager;
#[cfg(any(feature = "legacy_accounts", feature = "oauth2"))]
use pointercrate_core::pool::PointercratePool;
#[cfg(feature = "oauth2")]
use pointercrate_core_api::error::ErrorResponder;
use pointercrate_core_api::response::Page;
use pointercrate_core_pages::head::HeadLike;
use pointercrate_user::error::UserError;
use pointercrate_user_pages::account::AccountPageConfig;

use rocket::{
http::{Cookie, CookieJar, SameSite, Status},
response::Redirect,
State,
};
use std::net::IpAddr;

#[cfg(feature = "legacy_accounts")]
use {
pointercrate_core::pool::PointercratePool,
pointercrate_user::{
auth::legacy::{LegacyAuthenticatedUser, Registration},
auth::AuthenticatedUser,
Expand All @@ -26,6 +33,134 @@ use {
rocket::serde::json::Json,
};

#[cfg(feature = "oauth2")]
#[derive(serde::Serialize, serde::Deserialize)]
struct OAuthClaims {
sub: String,
nonce: u64,
exp: u64,
}

#[cfg(feature = "oauth2")]
#[rocket::get("/authorize")]
pub fn authorize(
ip: IpAddr, ratelimits: &State<UserRatelimits>, cookies: &CookieJar<'_>, auth: Option<TokenAuth>,
) -> Result<Redirect, ErrorResponder> {
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use pointercrate_core::error::CoreError;
use pointercrate_user::config;
use rocket::time::OffsetDateTime;

ratelimits.login_attempts(ip)?;

let mut redirect_uri = "https://accounts.google.com/o/oauth2/v2/auth".to_string()
+ format!("?client_id={}", config::google_client_id()).as_str()
+ "&response_type=code"
+ "&prompt=consent"
+ "&scope=profile"
+ "&redirect_uri=http%3A%2F%2Flocalhost%3A1971%2Fcallback";

if let Some(auth) = auth {
let mut nonce = [0u8; 8];
getrandom::getrandom(&mut nonce).map_err(|err| CoreError::internal_server_error(err.to_string()))?;
let nonce = u64::from_le_bytes(nonce);

redirect_uri = redirect_uri
+ "&state="
+ &auth.user.generate_jwt(&OAuthClaims {
nonce,
sub: auth.user.user().id.to_string(),
exp: (SystemTime::now() + Duration::from_secs(5 * 60))
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_secs(),
});

cookies.add(
Cookie::build(("oauth_nonce", nonce.to_string()))
.http_only(true)
.secure(!cfg!(debug_assertions))
.expires(OffsetDateTime::now_utc() + Duration::from_secs(5 * 60))
.same_site(SameSite::Strict)
.path("/api/v1/auth/callback"),
)
}

Ok(Redirect::to(redirect_uri))
}

#[cfg(feature = "oauth2")]
#[rocket::get("/callback?<code>&<state>")]
pub async fn callback(
auth: Option<TokenAuth>, pool: &State<PointercratePool>, ip: IpAddr, ratelimits: &State<UserRatelimits>, code: &str,
state: Option<&str>, cookies: &CookieJar<'_>,
) -> Result<rocket::response::content::RawHtml<&'static str>, ErrorResponder> {
use pointercrate_core::error::CoreError;
use pointercrate_user::auth::AuthenticatedUser;
use rocket::response::content::RawHtml;

ratelimits.login_attempts(ip)?;

let user = match (state, auth) {
(Some(jwt), Some(mut auth)) => {
let claims = auth.user.validate_jwt::<OAuthClaims>(jwt, Default::default())?;

let nonce = cookies.get("oauth_nonce").ok_or(UserError::Core(CoreError::Unauthorized))?;

if nonce.value() != claims.nonce.to_string() {
return Err(CoreError::Unauthorized.into());
}

cookies.remove(nonce.clone());

let user = auth.user.upgrade_legacy_account(code, &mut auth.connection).await?;

auth.connection.commit().await.map_err(UserError::from)?;

user
},

(None, None) => {
let mut connection = pool.transaction().await.map_err(UserError::from)?;
let user = AuthenticatedUser::by_oauth_code(code, &mut connection).await?;
connection.commit().await.map_err(UserError::from)?;
user
},

// If we do not have the state parameter, it means that we were not logged in during the request to /authorize, e.g.
// we wanted to create a new account. However, if now we are logged in, we could be in the scenario where some attacker
// started the oauth flow, and then tricked someone else into clicking the callback link they got from their "registration attempt".
// In other words: We cannot verify that the person logged in now is the same person that originally called /authorize.
// Thus return 401 UNAUTHORIZED.
_ => return Err(CoreError::Unauthorized.into()),
};

let cookie = Cookie::build(("access_token", user.generate_access_token()))
.http_only(true)
.secure(!cfg!(debug_assertions))
.same_site(SameSite::Strict)
.path("/");

cookies.add(cookie);

// We cannot use a HTTP redirect here, because HTTP redirect preserve "Referer" informatoin. Since we arrive
// at /callback after a redirect from google, this data will point to some google domain, and thus if we redirect
// here, we will open /account in the context of this referal from google. However, our access_token cookie is set
// with "Same-Site: strict", meaning it is not sent along for requests that are the result of a cross-domain referal,
// so even if we successfully login, we would just be dropped off at the login screen, until the user manually
// navigates somewhere else.
//
// However, "redirects" initiated by javascript loose the referer context, and thus if we instead do the below,
// the browser will send the access_token cookie along with the next request, and we end up on the profile
// page, as wanted.
Ok(RawHtml(
r#"
<html><head><title>Redirecting...</title><body>Redirecting...</body><script>window.location="/account"</script></html>
"#,
))
}

#[rocket::get("/login")]
pub async fn login_page(auth: Option<TokenAuth>) -> Result<Redirect, Page> {
auth.map(|_| Redirect::to(rocket::uri!(account_page)))
Expand Down
3 changes: 2 additions & 1 deletion pointercrate-user-pages/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ async-trait = "0.1.82"
sqlx = { version = "0.8", default-features = false, features = [ "runtime-tokio-native-tls", "macros", "postgres", "chrono", "migrate" ] }

[features]
legacy_accounts = ["pointercrate-user/legacy_accounts"]
legacy_accounts = ["pointercrate-user/legacy_accounts"]
oauth2 = ["pointercrate-user/oauth2"]
13 changes: 10 additions & 3 deletions pointercrate-user-pages/src/account/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,12 @@ impl AccountPageTab for ProfileTab {
}
div.flex.no-stretch {
input.button.red.hover #delete-account type = "button" style = "margin: 15px auto 0px;" value="Delete My Account";
input.button.blue.hover #change-password type = "button" style = "margin: 15px auto 0px;" value="Change Password";
@if authenticated_user.is_legacy() {
input.button.blue.hover #change-password type = "button" style = "margin: 15px auto 0px;" value="Change Password";
a.button.blue.hover #link-google href="/api/v1/auth/authorize?legacy=true" type = "button" style = "margin: 15px auto 0px;" {
"Link Google"
};
}
}
}
}
Expand Down Expand Up @@ -155,7 +160,7 @@ impl AccountPageTab for ProfileTab {
(edit_display_name_dialog())
(edit_youtube_link_dialog())
(change_password_dialog())
(delete_account_dialog())
(delete_account_dialog(!authenticated_user.is_legacy()))
}
}
}
Expand Down Expand Up @@ -258,7 +263,9 @@ fn change_password_dialog() -> Markup {
}
}

fn delete_account_dialog() -> Markup {
fn delete_account_dialog(is_google: bool) -> Markup {
// TODO: Add an alternative flow for Google authenticated users

html! {
div.overlay.closable {
div.dialog #delete-acc-dialog {
Expand Down
Loading