From 750b3d154c2ed9d87d694eae6d1c7d249f7fb926 Mon Sep 17 00:00:00 2001 From: red binder Date: Sun, 7 Jun 2026 09:52:57 +0200 Subject: [PATCH] Add OAuth support for the forums the current login page is only meant to act as a placeholder until our designer is back online --- res/login.html | 38 ++++++ src/json_api/oauth/authorize.rs | 90 ++++++++++++++ src/json_api/oauth/generate_token.rs | 173 ++++++++++++++++++++++++--- src/json_api/oauth/mod.rs | 2 +- src/json_api/users/profile.rs | 6 +- src/main.rs | 2 + src/nnid/people.rs | 133 ++++++++++++++++++++ 7 files changed, 423 insertions(+), 21 deletions(-) create mode 100644 res/login.html create mode 100644 src/json_api/oauth/authorize.rs diff --git a/res/login.html b/res/login.html new file mode 100644 index 0000000..15c8347 --- /dev/null +++ b/res/login.html @@ -0,0 +1,38 @@ + + + + + + Login - SPFN + + + +
+

Sign In

+
+ + + + +
+ + +
+
+ + +
+ +
+
+ + \ No newline at end of file diff --git a/src/json_api/oauth/authorize.rs b/src/json_api/oauth/authorize.rs new file mode 100644 index 0000000..07be2e9 --- /dev/null +++ b/src/json_api/oauth/authorize.rs @@ -0,0 +1,90 @@ +use rocket::FromForm; +use rocket::{get, post, response::Redirect, http::{Status, ContentType}, State, form::Form}; +use std::fs; +use rand::prelude::*; +use sqlx::Row; +use chrono::{Utc, Duration}; +use crate::json_api::oauth::generate_token::verify_nintendo_password; +use crate::Pool; + +#[derive(FromForm)] +pub struct AuthorizeQueryParams<'r> { + pub response_type: &'r str, + pub client_id: &'r str, + pub redirect_uri: &'r str, + pub state: Option<&'r str>, +} + +#[derive(FromForm)] +pub struct AuthorizeFormSubmit<'r> { + pub username: &'r str, + pub password: &'r str, + pub client_id: &'r str, + pub redirect_uri: &'r str, + pub state: Option<&'r str>, +} + +#[get("/api/v2/oauth2/authorize?")] +pub async fn authorize_page(params: AuthorizeQueryParams<'_>) -> Result<(ContentType, String), Status> { + if params.response_type != "code" { + return Err(Status::BadRequest); + } + + let html_content = fs::read_to_string("res/login.html") + .map_err(|_| Status::InternalServerError)?; + + let rendered = html_content + .replace("{{client_id}}", params.client_id) + .replace("{{redirect_uri}}", params.redirect_uri) + .replace("{{state}}", params.state.unwrap_or("")); + + Ok((ContentType::HTML, rendered)) +} + +#[post("/api/v2/oauth2/authorize/submit", data = "")] +pub async fn authorize_submit( + pool: &State, + form_data: Form> +) -> Result { + + let user_row = match sqlx::query("SELECT pid, password FROM users WHERE username = $1") + .bind(form_data.username) + .fetch_optional(pool.inner()) + .await + { + Ok(Some(row)) => row, + _ => return Err((Status::Unauthorized, "invalid_credentials")), + }; + + let pid: i32 = user_row.get("pid"); + let db_bcrypt_hash: String = user_row.get("password"); + + let mut random_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut random_bytes); + let secure_auth_code = hex::encode(random_bytes); + let expires_at = Utc::now().naive_utc() + Duration::minutes(5); + + if let Err(db_err) = sqlx::query( + "INSERT INTO oauth_auth_codes (code, pid, redirect_uri, expires_at) VALUES ($1, $2, $3, $4)" + ) + .bind(&secure_auth_code) + .bind(pid) + .bind(form_data.redirect_uri) + .bind(expires_at) + .execute(pool.inner()) + .await { + eprintln!("failed to save auth code: {:?}", db_err); + return Err((Status::InternalServerError, "server error saving session")); + } + + if !verify_nintendo_password(pid, form_data.password, &db_bcrypt_hash) { + return Err((Status::Unauthorized, "invalid_credentials")); + } + + let mut target_redirect = format!("{}?code={}", form_data.redirect_uri, secure_auth_code); + if let Some(state) = form_data.state { + target_redirect.push_str(&format!("&state={}", state)); + } + + Ok(Redirect::to(target_redirect)) +} \ No newline at end of file diff --git a/src/json_api/oauth/generate_token.rs b/src/json_api/oauth/generate_token.rs index fb14912..ebd8873 100644 --- a/src/json_api/oauth/generate_token.rs +++ b/src/json_api/oauth/generate_token.rs @@ -1,27 +1,166 @@ -use chrono::{Duration, NaiveDateTime, Utc}; -use rocket::{get, State}; -use rocket::serde::json::Json; +use crate::Pool; use serde::Serialize; -use crate::account::account::Auth; +use rocket::FromForm; +use sha2::{Sha256, Digest}; +use bytemuck::bytes_of; +use rocket::{post, State, form::Form, http::Status, serde::json::Json, error}; +use sqlx::Row; +use std::env; +use once_cell::sync::Lazy; use crate::nnid::oauth::generate_token::create_token; use crate::nnid::oauth::generate_token::token_type::AUTH_TOKEN; -use crate::Pool; +use chrono::Utc; + +pub static CLIENT_SECRET: Lazy = Lazy::new(|| { + env::var("OAUTH_CLIENT_SECRET").expect("OAUTH_CLIENT_SECRET not set") +}); #[derive(Serialize)] -pub struct TokenData{ - token: String, - expiry: NaiveDateTime +pub struct OAuthTokenResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: i64, } -#[get("/api/v2/oauth2/generate_token")] -pub async fn generate_token(pool: &State, auth: Auth) -> Json{ - let pool = pool.inner(); +#[derive(FromForm)] +pub struct TokenRequest<'r> { + pub grant_type: &'r str, + pub username: Option<&'r str>, + pub password: Option<&'r str>, + pub client_id: Option<&'r str>, + pub client_secret: Option<&'r str>, + pub code: Option<&'r str>, + pub redirect_uri: Option<&'r str>, +} - Json( - TokenData{ - expiry: Utc::now().naive_utc() + Duration::hours(1), - token: create_token(pool, auth.pid, AUTH_TOKEN, None).await, +pub fn verify_nintendo_password(pid: i32, text_password: &str, db_bcrypt_hash: &str) -> bool { + // maple: binder, there's already a function for this, why duplicate code? + // binder: dear maple, with this function i can just hand it a pid, the password from the user and the db_bcrypt_hash and it gives me a true/false value. + let mut sha = Sha256::new(); + sha.update(bytes_of(&pid)); + sha.update(&[0x02, 0x65, 0x43, 0x46]); + sha.update(text_password.as_bytes()); + let hashed_password_hex = hex::encode(sha.finalize()); + match bcrypt::verify(hashed_password_hex, db_bcrypt_hash) { + Ok(valid) => valid, + Err(_) => false, + } +} - } - ) +// dummy error responses +#[derive(Serialize)] +pub struct OAuthErrorResponse { + pub error: String, +} + +#[post("/api/v2/oauth2/generate_token", data = "")] +pub async fn generate_token( + pool: &State, + form_data: Form> +) -> Result, (Status, Json)> { + + if form_data.client_id != Some("account") { + return Err(( + Status::BadRequest, + Json(OAuthErrorResponse { error: "invalid_client".to_string() }) + )); + } + + let cl_secret: String = CLIENT_SECRET.clone(); + if form_data.client_secret != Some(&cl_secret) { + return Err(( + Status::Unauthorized, + Json(OAuthErrorResponse { error: "invalid_client".to_string() }) + )); + } + + // i'm only supporting the password grant incase someone feels lazy. + let pid: i32 = match form_data.grant_type { + "password" => { + let username = form_data.username.ok_or(( + Status::BadRequest, + Json(OAuthErrorResponse { error: "invalid_request".to_string() }) + ))?; + let password = form_data.password.ok_or(( + Status::BadRequest, + Json(OAuthErrorResponse { error: "invalid_request".to_string() }) + ))?; + + let user_row = match sqlx::query("SELECT pid, password FROM users WHERE username = $1") + .bind(username) + .fetch_optional(pool.inner()) + .await + { + Ok(Some(row)) => row, + _ => return Err(( + Status::BadRequest, + Json(OAuthErrorResponse { error: "invalid_grant".to_string() }) + )), + }; + + let db_pid: i32 = user_row.get("pid"); + let db_bcrypt_hash: String = user_row.get("password"); + + if !verify_nintendo_password(db_pid, password, &db_bcrypt_hash) { + return Err(( + Status::Unauthorized, + Json(OAuthErrorResponse { error: "invalid_grant".to_string() }) + )); + } + + db_pid + }, + + "authorization_code" => { + let incoming_code = form_data.code.ok_or(( + Status::BadRequest, + Json(OAuthErrorResponse { error: "invalid_request".to_string() }) + ))?; + + let code_row = match sqlx::query( + "SELECT pid, expires_at, used FROM oauth_auth_codes WHERE code = $1" + ) + .bind(incoming_code) + .fetch_optional(pool.inner()) + .await + { + Ok(Some(row)) => row, + _ => return Err(( + Status::BadRequest, + Json(OAuthErrorResponse { error: "invalid_grant".to_string() }) + )), + }; + + let is_used: bool = code_row.get("used"); + let expires_at: chrono::NaiveDateTime = code_row.get("expires_at"); + let target_pid: i32 = code_row.get("pid"); + + if is_used || expires_at < Utc::now().naive_utc() { + return Err(( + Status::BadRequest, + Json(OAuthErrorResponse { error: "invalid_grant".to_string() }) + )); + } + + let _ = sqlx::query("UPDATE oauth_auth_codes SET used = TRUE WHERE code = $1") + .bind(incoming_code) + .execute(pool.inner()) + .await; + + target_pid + }, + + _ => return Err(( + Status::BadRequest, + Json(OAuthErrorResponse { error: "unsupported_grant_type".to_string() }) + )) + }; + + let token = create_token(pool.inner(), pid, AUTH_TOKEN, None).await; + + Ok(Json(OAuthTokenResponse { + access_token: token, + token_type: "Bearer".to_string(), + expires_in: 3600, + })) } \ No newline at end of file diff --git a/src/json_api/oauth/mod.rs b/src/json_api/oauth/mod.rs index 0f3eae2..fdd4baa 100644 --- a/src/json_api/oauth/mod.rs +++ b/src/json_api/oauth/mod.rs @@ -1,2 +1,2 @@ pub mod generate_token; - +pub mod authorize; \ No newline at end of file diff --git a/src/json_api/users/profile.rs b/src/json_api/users/profile.rs index 81f11fb..b0c8966 100644 --- a/src/json_api/users/profile.rs +++ b/src/json_api/users/profile.rs @@ -1,10 +1,10 @@ use rocket::serde::json::Json; use rocket::{get, State}; use crate::account::account::Auth; -use crate::nnid::people::{build_profile, GetOwnProfileData}; +use crate::nnid::people::{build_oauth_profile, GetOwnOAuthProfileData}; use crate::Pool; #[get("/api/v2/users/@me/profile")] -pub async fn get_own_profile(_pool: &State, auth: Auth) -> Json { - Json(build_profile(auth.into())) +pub async fn get_own_profile(_pool: &State, auth: Auth) -> Json { + Json(build_oauth_profile(auth.into())) } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 7257e32..c7ba707 100644 --- a/src/main.rs +++ b/src/main.rs @@ -116,6 +116,8 @@ async fn launch() -> _ { json_api::users::profile::get_own_profile, json_api::users::mii::get_mii_data_by_pid, json_api::users::delete::delete_account, + json_api::oauth::authorize::authorize_page, + json_api::oauth::authorize::authorize_submit, nnid::people::thing, // graphql::graphiql, // graphql::playground, diff --git a/src/nnid/people.rs b/src/nnid/people.rs index bacb86c..0399c57 100644 --- a/src/nnid/people.rs +++ b/src/nnid/people.rs @@ -180,6 +180,20 @@ struct EmailInfoOwnProfileData{ validated_date: Option } +#[derive(Serialize)] +struct EmailInfoOwnOAuthProfileData{ + address: String, + id: u32, + parent: bool, + primary: bool, + reachable: bool, + #[serde(rename = "type")] + email_type: String, + updated_by: String, + validated: bool, + validated_date: Option +} + #[derive(Serialize)] struct MiiImage{ cached_url: String, @@ -230,6 +244,31 @@ pub struct GetOwnProfileData{ account_level: i32, } +#[derive(Serialize)] +#[serde(rename(serialize = "person"))] +pub struct GetOwnOAuthProfileData{ + active_flag: bool, + birth_date: NaiveDate, + country: String, + create_date: NaiveDateTime, + gender: String, + language: String, + updated: NaiveDateTime, + marketing_flag: bool, + off_device_flag: bool, + pid: i32, + id: i32, + uid: i32, + sub: i32, + email: EmailInfoOwnOAuthProfileData, + mii: MiiDataOwnProfileData, + region: i32, + tz_name: String, + user_id: String, + utc_offset: String, + account_level: i32, +} + #[get("/v1/api/people/@me/profile")] pub fn get_own_profile(user: Auth) -> Ds>{ Ds(Xml(build_profile(user.into()))) @@ -330,6 +369,99 @@ pub fn build_profile(user: User) -> GetOwnProfileData { } } +pub fn build_oauth_profile(user: User) -> GetOwnOAuthProfileData { + let User { + username, + pid, + account_level, + mii_data, + gender, + birthdate, + country, + creation_date, + timezone, + language, + email, + email_verified_since, + updated, + marketing_allowed, + off_device_allowed, + region, + // verification_code, + .. + } = user.into(); + + let timezone_offset = (&*OFFSET_FROM_TIMEZONE).get(&timezone).unwrap().to_owned(); + + // very bruteforce method, but i don't care. + let id = pid; + let sub = pid; + let uid = pid; + + let mii_data = mii_data + .replace("\n", "") + .replace("\t", "") + .replace("\r", "") + .replace(" ", ""); + + + GetOwnOAuthProfileData { + id, + sub, + uid, + active_flag: true, + pid, + user_id: username, + gender, + birth_date: birthdate, + country, + create_date: creation_date, + tz_name: timezone, + language, + updated, + marketing_flag: marketing_allowed, + email: EmailInfoOwnOAuthProfileData { + id: gxhash32(email.as_bytes(), 0), + address: email, + validated: email_verified_since.is_some(), + validated_date: email_verified_since, + email_type: "DEFAULT".to_string(), + updated_by: "USER".to_string(), + reachable: true, + primary: true, + parent: false, + }, + mii: MiiDataOwnProfileData { + id: gxhash32(mii_data.as_bytes(), 0), + mii_hash: hex::encode(bytemuck::bytes_of( + &(gxhash64(mii_data.as_bytes(), 1) & !(0x1000000000000000)) + )), + name: crate::mii_util::MiiData::read(&mii_data) + .map(|v| v.name) + .unwrap_or_else(|| "INVALID".to_string()), + primary: YesNoVal(true), + data: mii_data, + status: "COMPLETED".to_string(), + mii_images: MiiImages { + mii_image: { + let image_url = get_mii_img_url(pid, "png"); + let url_hash = gxhash32(image_url.as_bytes(), 0); + MiiImage { + image_type: "standard".to_string(), + id: url_hash, + url: image_url.clone(), + cached_url: image_url, + } + } + } + }, + off_device_flag: off_device_allowed, + region, + utc_offset: timezone_offset, + account_level, + } +} + #[put("/v1/api/people/@me/miis/@primary", data = "")] pub async fn change_mii( database: &State, @@ -360,6 +492,7 @@ pub async fn change_mii( Ok(()) } + #[post("/v1/api/people/@me/agreements")] pub async fn thing(){