serial
Some checks failed
Build and Test / account (push) Failing after 1m55s

This commit is contained in:
Maple Nebel 2026-07-15 17:37:53 +02:00
commit f32e69be99
4 changed files with 219 additions and 110 deletions

View file

@ -117,7 +117,10 @@ pub fn generate_password(pid: i32, cleartext_password: &str) -> Option<String> {
bcrypt::hash(password, 10).ok() bcrypt::hash(password, 10).ok()
} }
pub async fn read_basic_auth_token(connection: &Pool, token: &str) -> Option<User> { pub async fn read_basic_auth_token(
connection: &Pool,
token: &str,
) -> Option<(User, Option<[u8; 32]>)> {
let data = match BASE64_STANDARD.decode(&token) { let data = match BASE64_STANDARD.decode(&token) {
Ok(d) => d, Ok(d) => d,
Err(e) => { Err(e) => {
@ -161,13 +164,16 @@ pub async fn read_basic_auth_token(connection: &Pool, token: &str) -> Option<Use
let password_valid = user.verify_cleartext_password(&login_password); let password_valid = user.verify_cleartext_password(&login_password);
if password_valid == Some(true) { if password_valid == Some(true) {
Some(user) Some((user, None))
} else { } else {
None None
} }
} }
pub async fn read_bearer_auth_token(connection: &Pool, token: &str) -> Option<User> { pub async fn read_bearer_auth_token(
connection: &Pool,
token: &str,
) -> Option<(User, Option<[u8; 32]>)> {
let data = TokenData::decode(token)?; let data = TokenData::decode(token)?;
let token_info = sqlx::query!( let token_info = sqlx::query!(
@ -189,11 +195,11 @@ pub async fn read_bearer_auth_token(connection: &Pool, token: &str) -> Option<Us
.await .await
.ok()?; .ok()?;
Some(user) Some((user, token_info.cert_hash.and_then(|v| v.try_into().ok())))
} }
pub fn generate_nex_password() -> String { pub fn generate_nex_password() -> String {
let mut rng = rand::rng(); let mut rng = rand::rng();
let mut output = String::with_capacity(16); let mut output = String::with_capacity(16);
while output.len() < 16 { while output.len() < 16 {
@ -214,6 +220,7 @@ pub fn generate_nex_password() -> String {
pub struct Auth<const FORCE_BEARER_AUTH: bool = true, const USE_CERT: bool = FORCE_BEARER_AUTH>( pub struct Auth<const FORCE_BEARER_AUTH: bool = true, const USE_CERT: bool = FORCE_BEARER_AUTH>(
pub User, pub User,
pub Option<[u8; 32]>,
); );
impl<const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> AsRef<User> impl<const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> AsRef<User>
@ -278,6 +285,7 @@ pub async fn link_certificate_to_pid(
Ok(()) Ok(())
} }
// todo: make this more consistent by requiring a cert to be asociated to the token if USE_CERT is set
#[async_trait] #[async_trait]
impl<'r, const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> FromRequest<'r> impl<'r, const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> FromRequest<'r>
for Auth<FORCE_BEARER_AUTH, USE_CERT> for Auth<FORCE_BEARER_AUTH, USE_CERT>
@ -297,13 +305,13 @@ impl<'r, const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> FromRequest<'r>
let (auth_type, token) = request_try!(auth.split_once(' ').ok_or(INVALID_TOKEN_ERRORS)); let (auth_type, token) = request_try!(auth.split_once(' ').ok_or(INVALID_TOKEN_ERRORS));
let user = match auth_type { let data = match auth_type {
"Basic" if !FORCE_BEARER_AUTH => read_basic_auth_token(pool, token).await, "Basic" if !FORCE_BEARER_AUTH => read_basic_auth_token(pool, token).await,
"Bearer" => read_bearer_auth_token(pool, token).await, "Bearer" => read_bearer_auth_token(pool, token).await,
_ => return Outcome::Error((Status::BadRequest, INVALID_TOKEN_ERRORS)), _ => return Outcome::Error((Status::BadRequest, INVALID_TOKEN_ERRORS)),
}; };
let Some(user) = user else { let Some((user, cert)) = data else {
return Outcome::Error((Status::BadRequest, INVALID_TOKEN_ERRORS)); return Outcome::Error((Status::BadRequest, INVALID_TOKEN_ERRORS));
}; };
@ -316,7 +324,7 @@ impl<'r, const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> FromRequest<'r>
// ..user // ..user
// }; // };
Outcome::Success(Self(user)) Outcome::Success(Self(user, cert))
} }
} }

View file

@ -1,19 +1,18 @@
use crate::Pool; use crate::Pool;
use serde::Serialize;
use rocket::FromForm;
use sha2::{Sha256, Digest};
use bytemuck::bytes_of;
use rocket::{post, State, form::Form, http::Status, serde::json::Json};
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::create_token;
use crate::nnid::oauth::generate_token::token_type::AUTH_TOKEN; use crate::nnid::oauth::generate_token::token_type::AUTH_TOKEN;
use bytemuck::bytes_of;
use chrono::Utc; use chrono::Utc;
use once_cell::sync::Lazy;
use rocket::FromForm;
use rocket::{State, form::Form, http::Status, post, serde::json::Json};
use serde::Serialize;
use sha2::{Digest, Sha256};
use sqlx::Row;
use std::env;
pub static CLIENT_SECRET: Lazy<String> = Lazy::new(|| { pub static CLIENT_SECRET: Lazy<String> =
env::var("OAUTH_CLIENT_SECRET").expect("OAUTH_CLIENT_SECRET not set") Lazy::new(|| env::var("OAUTH_CLIENT_SECRET").expect("OAUTH_CLIENT_SECRET not set"));
});
#[derive(Serialize)] #[derive(Serialize)]
pub struct OAuthTokenResponse { pub struct OAuthTokenResponse {
@ -52,17 +51,18 @@ pub struct OAuthErrorResponse {
#[post("/api/v2/oauth2/generate_token", data = "<form_data>")] #[post("/api/v2/oauth2/generate_token", data = "<form_data>")]
pub async fn generate_token( pub async fn generate_token(
pool: &State<Pool>, pool: &State<Pool>,
form_data: Form<TokenRequest<'_>> form_data: Form<TokenRequest<'_>>,
) -> Result<Json<OAuthTokenResponse>, (Status, Json<OAuthErrorResponse>)> { ) -> Result<Json<OAuthTokenResponse>, (Status, Json<OAuthErrorResponse>)> {
match form_data.client_id { match form_data.client_id {
Some("account") | Some("splatnet") => { Some("account") | Some("splatnet") => {
let cl_secret: String = CLIENT_SECRET.clone(); let cl_secret: String = CLIENT_SECRET.clone();
if form_data.client_secret != Some(&cl_secret) { if form_data.client_secret != Some(&cl_secret) {
return Err(( return Err((
Status::Unauthorized, Status::Unauthorized,
Json(OAuthErrorResponse { error: "invalid_client".to_string() }) Json(OAuthErrorResponse {
error: "invalid_client".to_string(),
}),
)); ));
} }
} }
@ -72,7 +72,9 @@ pub async fn generate_token(
_ => { _ => {
return Err(( return Err((
Status::BadRequest, Status::BadRequest,
Json(OAuthErrorResponse { error: "invalid_client".to_string() }) Json(OAuthErrorResponse {
error: "invalid_client".to_string(),
}),
)); ));
} }
} }
@ -82,23 +84,31 @@ pub async fn generate_token(
"password" => { "password" => {
let username = form_data.username.ok_or(( let username = form_data.username.ok_or((
Status::BadRequest, Status::BadRequest,
Json(OAuthErrorResponse { error: "invalid_request".to_string() }) Json(OAuthErrorResponse {
error: "invalid_request".to_string(),
}),
))?; ))?;
let password = form_data.password.ok_or(( let password = form_data.password.ok_or((
Status::BadRequest, Status::BadRequest,
Json(OAuthErrorResponse { error: "invalid_request".to_string() }) Json(OAuthErrorResponse {
error: "invalid_request".to_string(),
}),
))?; ))?;
let user_row = match sqlx::query("SELECT pid, password FROM users WHERE username = $1") let user_row = match sqlx::query("SELECT pid, password FROM users WHERE username = $1")
.bind(username) .bind(username)
.fetch_optional(pool.inner()) .fetch_optional(pool.inner())
.await .await
{ {
Ok(Some(row)) => row, Ok(Some(row)) => row,
_ => return Err(( _ => {
Status::BadRequest, return Err((
Json(OAuthErrorResponse { error: "invalid_grant".to_string() }) Status::BadRequest,
)), Json(OAuthErrorResponse {
error: "invalid_grant".to_string(),
}),
));
}
}; };
let db_pid: i32 = user_row.get("pid"); let db_pid: i32 = user_row.get("pid");
@ -107,31 +117,39 @@ pub async fn generate_token(
if !verify_nintendo_password(db_pid, password, &db_bcrypt_hash) { if !verify_nintendo_password(db_pid, password, &db_bcrypt_hash) {
return Err(( return Err((
Status::Unauthorized, Status::Unauthorized,
Json(OAuthErrorResponse { error: "invalid_grant".to_string() }) Json(OAuthErrorResponse {
error: "invalid_grant".to_string(),
}),
)); ));
} }
db_pid db_pid
}, }
"authorization_code" => { "authorization_code" => {
let incoming_code = form_data.code.ok_or(( let incoming_code = form_data.code.ok_or((
Status::BadRequest, Status::BadRequest,
Json(OAuthErrorResponse { error: "invalid_request".to_string() }) Json(OAuthErrorResponse {
error: "invalid_request".to_string(),
}),
))?; ))?;
let code_row = match sqlx::query( let code_row = match sqlx::query(
"SELECT pid, expires_at, used FROM oauth_auth_codes WHERE code = $1" "SELECT pid, expires_at, used FROM oauth_auth_codes WHERE code = $1",
) )
.bind(incoming_code) .bind(incoming_code)
.fetch_optional(pool.inner()) .fetch_optional(pool.inner())
.await .await
{ {
Ok(Some(row)) => row, Ok(Some(row)) => row,
_ => return Err(( _ => {
Status::BadRequest, return Err((
Json(OAuthErrorResponse { error: "invalid_grant".to_string() }) Status::BadRequest,
)), Json(OAuthErrorResponse {
error: "invalid_grant".to_string(),
}),
));
}
}; };
let is_used: bool = code_row.get("used"); let is_used: bool = code_row.get("used");
@ -140,8 +158,10 @@ pub async fn generate_token(
if is_used || expires_at < Utc::now().naive_utc() { if is_used || expires_at < Utc::now().naive_utc() {
return Err(( return Err((
Status::BadRequest, Status::BadRequest,
Json(OAuthErrorResponse { error: "invalid_grant".to_string() }) Json(OAuthErrorResponse {
error: "invalid_grant".to_string(),
}),
)); ));
} }
@ -151,15 +171,19 @@ pub async fn generate_token(
.await; .await;
target_pid target_pid
}, }
_ => return Err(( _ => {
Status::BadRequest, return Err((
Json(OAuthErrorResponse { error: "unsupported_grant_type".to_string() }) Status::BadRequest,
)) Json(OAuthErrorResponse {
error: "unsupported_grant_type".to_string(),
}),
));
}
}; };
let token = create_token(pool.inner(), pid, AUTH_TOKEN, None).await; let token = create_token(pool.inner(), pid, AUTH_TOKEN, None, None).await;
Ok(Json(OAuthTokenResponse { Ok(Json(OAuthTokenResponse {
access_token: token, access_token: token,

View file

@ -1,50 +1,43 @@
#![allow(unused)] #![allow(unused)]
use rocket::{post, FromForm, State}; use crate::Pool;
use rocket::form::Form;
use serde::{Serialize};
use crate::account::account::{Auth, DeviceCert, User, link_certificate_to_pid}; use crate::account::account::{Auth, DeviceCert, User, link_certificate_to_pid};
use crate::error::{Error, Errors}; use crate::error::{Error, Errors};
use crate::nnid::agreements::{CFIP, EVIL_AGREEMENT_THING}; use crate::nnid::agreements::{CFIP, EVIL_AGREEMENT_THING};
use crate::nnid::oauth::generate_token::token_type::{AUTH_REFRESH_TOKEN, AUTH_TOKEN};
use crate::nnid::oauth::TokenData; use crate::nnid::oauth::TokenData;
use crate::Pool; use crate::nnid::oauth::generate_token::token_type::{AUTH_REFRESH_TOKEN, AUTH_TOKEN};
use crate::xml::Xml; use crate::xml::Xml;
use rocket::form::Form;
use rocket::{FromForm, State, post};
use serde::Serialize;
pub mod token_type{ pub mod token_type {
pub const AUTH_REFRESH_TOKEN: i32 = 1; pub const AUTH_REFRESH_TOKEN: i32 = 1;
pub const AUTH_TOKEN: i32 = 0; pub const AUTH_TOKEN: i32 = 0;
pub const NEX_TOKEN: i32 = 2; pub const NEX_TOKEN: i32 = 2;
} }
const ACCOUNT_ID_OR_PASSWORD_ERRORS: Errors = Errors{ const ACCOUNT_ID_OR_PASSWORD_ERRORS: Errors = Errors {
error: &[ error: &[Error {
Error{ code: "0106",
code: "0106", message: "Invalid account ID or password",
message: "Invalid account ID or password" }],
}
]
}; };
const ACCOUNT_BANNED_ERRORS: Errors = Errors{ const ACCOUNT_BANNED_ERRORS: Errors = Errors {
error: &[ error: &[Error {
Error{ code: "0108",
code: "0108", message: "Account banned from server",
message: "Account banned from server" }],
}
]
}; };
const REREAD_EULA_EXTRABANNED_ERRORS: Errors = Errors {
const REREAD_EULA_EXTRABANNED_ERRORS: Errors = Errors{ error: &[Error {
error: &[ code: "0109",
Error{ message: "REREAD THE EULA LOL",
code: "0109", }],
message: "REREAD THE EULA LOL"
}
]
}; };
#[derive(FromForm)] #[derive(FromForm)]
pub struct TokenRequestData<'a>{ pub struct TokenRequestData<'a> {
grant_type: &'a str, grant_type: &'a str,
user_id: &'a str, user_id: &'a str,
password: &'a str, password: &'a str,
@ -55,17 +48,27 @@ pub struct TokenRequestData<'a>{
pub struct TokenReturnData { pub struct TokenReturnData {
token: String, token: String,
refresh_token: String, refresh_token: String,
expires_in: i32 expires_in: i32,
} }
pub async fn create_token(pool: &Pool, pid: i32, token_type: i32, title_id: Option<&str>) -> String{ pub async fn create_token(
pool: &Pool,
pid: i32,
token_type: i32,
title_id: Option<&str>,
cert_hash: Option<&[u8]>,
) -> String {
let data = sqlx::query!( let data = sqlx::query!(
"insert into tokens (token_type, pid, title_id) "insert into tokens (token_type, pid, title_id, cert_hash)
values ($1, $2, $3) returning token_id, random", values ($1, $2, $3, $4) returning token_id, random",
token_type, pid, title_id token_type,
) pid,
.fetch_one(pool) title_id,
.await.unwrap(); cert_hash
)
.fetch_one(pool)
.await
.unwrap();
let token_id = data.token_id; let token_id = data.token_id;
let random = data.random; let random = data.random;
@ -73,61 +76,79 @@ pub async fn create_token(pool: &Pool, pid: i32, token_type: i32, title_id: Opti
let token = TokenData { let token = TokenData {
token_id, token_id,
random, random,
pid pid,
}; };
token.encode().to_string() token.encode().to_string()
} }
impl TokenReturnData { impl TokenReturnData {
async fn new(pid: i32, pool: &Pool) -> Self{ async fn new(pid: i32, pool: &Pool) -> Self {
let token = create_token(pool, pid, AUTH_TOKEN, None).await; let token = create_token(pool, pid, AUTH_TOKEN, None, None).await;
let refresh_token = create_token(pool, pid, AUTH_REFRESH_TOKEN, None).await; let refresh_token = create_token(pool, pid, AUTH_REFRESH_TOKEN, None, None).await;
Self{ Self {
token, token,
refresh_token, refresh_token,
expires_in: 3600 expires_in: 3600,
}
}
async fn new_with_cert(pid: i32, cert: Option<&[u8]>, pool: &Pool) -> Self {
let token = create_token(pool, pid, AUTH_TOKEN, None, None).await;
let refresh_token = create_token(pool, pid, AUTH_REFRESH_TOKEN, None, None).await;
Self {
token,
refresh_token,
expires_in: 3600,
} }
} }
} }
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename="OAuth20")] #[serde(rename = "OAuth20")]
pub struct TokenRequestReturnData{ pub struct TokenRequestReturnData {
access_token: TokenReturnData access_token: TokenReturnData,
} }
#[post("/v1/api/oauth20/access_token/generate", data="<data>")] #[post("/v1/api/oauth20/access_token/generate", data = "<data>")]
pub async fn generate_token(pool: &State<Pool>, data: Form<TokenRequestData<'_>>, ip: CFIP, cert: DeviceCert) -> Result<Xml<TokenRequestReturnData>, Option<Errors<'static>>>{ pub async fn generate_token(
pool: &State<Pool>,
data: Form<TokenRequestData<'_>>,
ip: CFIP,
cert: DeviceCert,
) -> Result<Xml<TokenRequestReturnData>, Option<Errors<'static>>> {
let pool = pool.inner(); let pool = pool.inner();
let user = User::get_by_username(data.user_id, pool).await let user = User::get_by_username(data.user_id, pool)
.await
.ok_or(Some(ACCOUNT_ID_OR_PASSWORD_ERRORS))?; .ok_or(Some(ACCOUNT_ID_OR_PASSWORD_ERRORS))?;
if !user.verify_hashed_password(&data.password).is_some_and(|v| v){ if !user
.verify_hashed_password(&data.password)
.is_some_and(|v| v)
{
return Err(Some(ACCOUNT_ID_OR_PASSWORD_ERRORS)); return Err(Some(ACCOUNT_ID_OR_PASSWORD_ERRORS));
} }
if user.account_level < 0{ if user.account_level < 0 {
if user.account_level == -2 { if user.account_level == -2 {
return Err(Some(REREAD_EULA_EXTRABANNED_ERRORS)); return Err(Some(REREAD_EULA_EXTRABANNED_ERRORS));
} }
if user.account_level == -3{ if user.account_level == -3 {
EVIL_AGREEMENT_THING.write().await.insert(ip.0); EVIL_AGREEMENT_THING.write().await.insert(ip.0);
return Err(Some(REREAD_EULA_EXTRABANNED_ERRORS)); return Err(Some(REREAD_EULA_EXTRABANNED_ERRORS));
} }
return Err(Some(ACCOUNT_BANNED_ERRORS)); return Err(Some(ACCOUNT_BANNED_ERRORS));
} }
link_certificate_to_pid(&pool, &cert.0, user.pid).await?; link_certificate_to_pid(&pool, &cert.0, user.pid).await?;
let access_token = TokenReturnData::new(user.pid, pool).await; let access_token =
TokenReturnData::new_with_cert(user.pid, Some(&cert.0.hash()[..]), pool).await;
Ok(Xml(TokenRequestReturnData{ Ok(Xml(TokenRequestReturnData { access_token }))
access_token }
}))
}

View file

@ -4,10 +4,14 @@ use crate::error::{Error, Errors};
use crate::nnid::oauth::generate_token::create_token; use crate::nnid::oauth::generate_token::create_token;
use crate::nnid::oauth::generate_token::token_type::NEX_TOKEN; use crate::nnid::oauth::generate_token::token_type::NEX_TOKEN;
use crate::xml::Xml; use crate::xml::Xml;
use log::{info, warn};
use nex_account::grpc::Pid; use nex_account::grpc::Pid;
use reqwest::header::SERVER; use reqwest::header::SERVER;
use rocket::{State, get}; use rocket::http::Status;
use rocket::request::{FromRequest, Outcome, Request};
use rocket::{State, async_trait, get};
use serde::Serialize; use serde::Serialize;
use sqlx::query;
use sqlx::types::ipnetwork::IpNetwork::V4; use sqlx::types::ipnetwork::IpNetwork::V4;
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
@ -55,10 +59,52 @@ pub struct ServiceToken {
token: String, token: String,
} }
pub async fn store_or_check_serial(pool: &Pool, serial: &str, cert_hash: [u8; 32]) -> bool {
let Ok(res) = query!(
"select serial from certificates where hash = $1",
&cert_hash[..]
)
.fetch_one(pool)
.await
else {
warn!(
"user tried to access a route which is locked behind a console asociated token without a console token"
);
return false;
};
let Some(stored_serial) = res.serial else {
query!(
"update certificates set serial = $1 where hash = $2",
serial,
&cert_hash[..]
);
return true;
};
serial == stored_serial
}
struct Serial(String);
#[async_trait]
impl<'r> FromRequest<'r> for Serial {
type Error = Errors<'static>;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let Some(header) = request.headers().get("X-Nintendo-Serial-Number").next() else {
warn!("serial number wasnt sent on request which expected a serial number");
return Outcome::Error((Status::BadRequest, SERVER_ERROR));
};
Outcome::Success(Self(header.to_owned()))
}
}
#[get("/v1/api/provider/service_token/@me")] #[get("/v1/api/provider/service_token/@me")]
pub async fn get_service_token( pub async fn get_service_token(
pool: &State<Pool>, pool: &State<Pool>,
auth: Auth<true, false>, auth: Auth<true, false>,
serial: Serial,
) -> Result<Xml<ServiceToken>, Option<Errors<'static>>> { ) -> Result<Xml<ServiceToken>, Option<Errors<'static>>> {
// just gonna put this here as a side note for the future: // just gonna put this here as a side note for the future:
// we could also be using key derivation to derive the nex token as if it were a key // we could also be using key derivation to derive the nex token as if it were a key
@ -69,7 +115,17 @@ pub async fn get_service_token(
let pool = pool.inner(); let pool = pool.inner();
let token = create_token(pool, auth.pid, NEX_TOKEN, None).await; let Some(cert_hash) = auth.1 else {
info!("attempt to generate service token using non wii u/certificate token");
return Err(Some(SERVER_ERROR));
};
if !store_or_check_serial(pool, &serial.0, cert_hash).await {
info!("serial mismatched with certificate");
return Err(Some(SERVER_ERROR));
}
let token = create_token(pool, auth.pid, NEX_TOKEN, None, Some(&cert_hash[..])).await;
Ok(Xml(ServiceToken { token })) Ok(Xml(ServiceToken { token }))
} }