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()
}
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) {
Ok(d) => d,
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);
if password_valid == Some(true) {
Some(user)
Some((user, None))
} else {
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 token_info = sqlx::query!(
@ -189,7 +195,7 @@ pub async fn read_bearer_auth_token(connection: &Pool, token: &str) -> Option<Us
.await
.ok()?;
Some(user)
Some((user, token_info.cert_hash.and_then(|v| v.try_into().ok())))
}
pub fn generate_nex_password() -> String {
@ -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 User,
pub Option<[u8; 32]>,
);
impl<const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> AsRef<User>
@ -278,6 +285,7 @@ pub async fn link_certificate_to_pid(
Ok(())
}
// todo: make this more consistent by requiring a cert to be asociated to the token if USE_CERT is set
#[async_trait]
impl<'r, const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> FromRequest<'r>
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 user = match auth_type {
let data = match auth_type {
"Basic" if !FORCE_BEARER_AUTH => read_basic_auth_token(pool, token).await,
"Bearer" => read_bearer_auth_token(pool, token).await,
_ => 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));
};
@ -316,7 +324,7 @@ impl<'r, const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> FromRequest<'r>
// ..user
// };
Outcome::Success(Self(user))
Outcome::Success(Self(user, cert))
}
}

View file

@ -1,19 +1,18 @@
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::token_type::AUTH_TOKEN;
use bytemuck::bytes_of;
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(|| {
env::var("OAUTH_CLIENT_SECRET").expect("OAUTH_CLIENT_SECRET not set")
});
pub static CLIENT_SECRET: Lazy<String> =
Lazy::new(|| env::var("OAUTH_CLIENT_SECRET").expect("OAUTH_CLIENT_SECRET not set"));
#[derive(Serialize)]
pub struct OAuthTokenResponse {
@ -53,16 +52,17 @@ pub struct OAuthErrorResponse {
#[post("/api/v2/oauth2/generate_token", data = "<form_data>")]
pub async fn generate_token(
pool: &State<Pool>,
form_data: Form<TokenRequest<'_>>
form_data: Form<TokenRequest<'_>>,
) -> Result<Json<OAuthTokenResponse>, (Status, Json<OAuthErrorResponse>)> {
match form_data.client_id {
Some("account") | Some("splatnet") => {
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() })
Json(OAuthErrorResponse {
error: "invalid_client".to_string(),
}),
));
}
}
@ -72,7 +72,9 @@ pub async fn generate_token(
_ => {
return Err((
Status::BadRequest,
Json(OAuthErrorResponse { error: "invalid_client".to_string() })
Json(OAuthErrorResponse {
error: "invalid_client".to_string(),
}),
));
}
}
@ -82,11 +84,15 @@ pub async fn generate_token(
"password" => {
let username = form_data.username.ok_or((
Status::BadRequest,
Json(OAuthErrorResponse { error: "invalid_request".to_string() })
Json(OAuthErrorResponse {
error: "invalid_request".to_string(),
}),
))?;
let password = form_data.password.ok_or((
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")
@ -95,10 +101,14 @@ pub async fn generate_token(
.await
{
Ok(Some(row)) => row,
_ => return Err((
_ => {
return Err((
Status::BadRequest,
Json(OAuthErrorResponse { error: "invalid_grant".to_string() })
)),
Json(OAuthErrorResponse {
error: "invalid_grant".to_string(),
}),
));
}
};
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) {
return Err((
Status::Unauthorized,
Json(OAuthErrorResponse { error: "invalid_grant".to_string() })
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() })
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"
"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((
_ => {
return Err((
Status::BadRequest,
Json(OAuthErrorResponse { error: "invalid_grant".to_string() })
)),
Json(OAuthErrorResponse {
error: "invalid_grant".to_string(),
}),
));
}
};
let is_used: bool = code_row.get("used");
@ -141,7 +159,9 @@ pub async fn generate_token(
if is_used || expires_at < Utc::now().naive_utc() {
return Err((
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;
target_pid
},
}
_ => return Err((
_ => {
return Err((
Status::BadRequest,
Json(OAuthErrorResponse { error: "unsupported_grant_type".to_string() })
))
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 {
access_token: token,

View file

@ -1,14 +1,14 @@
#![allow(unused)]
use rocket::{post, FromForm, State};
use rocket::form::Form;
use serde::{Serialize};
use crate::Pool;
use crate::account::account::{Auth, DeviceCert, User, link_certificate_to_pid};
use crate::error::{Error, Errors};
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::Pool;
use crate::nnid::oauth::generate_token::token_type::{AUTH_REFRESH_TOKEN, AUTH_TOKEN};
use crate::xml::Xml;
use rocket::form::Form;
use rocket::{FromForm, State, post};
use serde::Serialize;
pub mod token_type {
pub const AUTH_REFRESH_TOKEN: i32 = 1;
@ -17,31 +17,24 @@ pub mod token_type{
}
const ACCOUNT_ID_OR_PASSWORD_ERRORS: Errors = Errors {
error: &[
Error{
error: &[Error {
code: "0106",
message: "Invalid account ID or password"
}
]
message: "Invalid account ID or password",
}],
};
const ACCOUNT_BANNED_ERRORS: Errors = Errors {
error: &[
Error{
error: &[Error {
code: "0108",
message: "Account banned from server"
}
]
message: "Account banned from server",
}],
};
const REREAD_EULA_EXTRABANNED_ERRORS: Errors = Errors {
error: &[
Error{
error: &[Error {
code: "0109",
message: "REREAD THE EULA LOL"
}
]
message: "REREAD THE EULA LOL",
}],
};
#[derive(FromForm)]
pub struct TokenRequestData<'a> {
@ -55,17 +48,27 @@ pub struct TokenRequestData<'a>{
pub struct TokenReturnData {
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!(
"insert into tokens (token_type, pid, title_id)
values ($1, $2, $3) returning token_id, random",
token_type, pid, title_id
"insert into tokens (token_type, pid, title_id, cert_hash)
values ($1, $2, $3, $4) returning token_id, random",
token_type,
pid,
title_id,
cert_hash
)
.fetch_one(pool)
.await.unwrap();
.await
.unwrap();
let token_id = data.token_id;
let random = data.random;
@ -73,23 +76,33 @@ pub async fn create_token(pool: &Pool, pid: i32, token_type: i32, title_id: Opti
let token = TokenData {
token_id,
random,
pid
pid,
};
token.encode().to_string()
}
impl TokenReturnData {
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 {
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,
}
}
}
@ -97,17 +110,26 @@ impl TokenReturnData {
#[derive(Serialize)]
#[serde(rename = "OAuth20")]
pub struct TokenRequestReturnData {
access_token: TokenReturnData
access_token: TokenReturnData,
}
#[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 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))?;
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));
}
@ -125,9 +147,8 @@ pub async fn generate_token(pool: &State<Pool>, data: Form<TokenRequestData<'_>>
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{
access_token
}))
Ok(Xml(TokenRequestReturnData { 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::token_type::NEX_TOKEN;
use crate::xml::Xml;
use log::{info, warn};
use nex_account::grpc::Pid;
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 sqlx::query;
use sqlx::types::ipnetwork::IpNetwork::V4;
use std::net::Ipv4Addr;
@ -55,10 +59,52 @@ pub struct ServiceToken {
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")]
pub async fn get_service_token(
pool: &State<Pool>,
auth: Auth<true, false>,
serial: Serial,
) -> Result<Xml<ServiceToken>, Option<Errors<'static>>> {
// 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
@ -69,7 +115,17 @@ pub async fn get_service_token(
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 }))
}