feat: stuff
This commit is contained in:
parent
2e2b01990e
commit
6d58fd47a1
11 changed files with 244 additions and 366 deletions
|
|
@ -5,6 +5,7 @@ use argon2::password_hash::SaltString;
|
|||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use bytemuck::bytes_of;
|
||||
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
|
||||
use log::{error, warn};
|
||||
use rocket::http::Status;
|
||||
use rocket::{async_trait, Request};
|
||||
|
|
@ -34,18 +35,21 @@ const INVALID_TOKEN_ERRORS: Errors<'static> = Errors{
|
|||
|
||||
// optimization note: add token caching
|
||||
pub struct User {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub pid: i32,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub birthdate: chrono::NaiveDate,
|
||||
pub birthdate: NaiveDate,
|
||||
pub timezone: String,
|
||||
pub email: String,
|
||||
pub account_level: i32,
|
||||
pub email_verified_since: Option<NaiveDateTime>,
|
||||
pub gender: String,
|
||||
pub country: String,
|
||||
pub language: String,
|
||||
pub marketing_allowed: bool,
|
||||
pub off_device_allowed: bool,
|
||||
pub region: i32,
|
||||
pub mii_identifier: String
|
||||
pub mii_data: String
|
||||
}
|
||||
|
||||
fn generate_nintendo_hash(pid: i32, text_password: &str) -> String{
|
||||
|
|
@ -59,40 +63,39 @@ fn generate_nintendo_hash(pid: i32, text_password: &str) -> String{
|
|||
}
|
||||
|
||||
impl User{
|
||||
fn generate_nintendo_hash(&self, text_password: &str) -> String{
|
||||
generate_nintendo_hash(self.id, text_password)
|
||||
pub async fn get_by_username(name: &str, pool: &Pool) -> Option<Self>{
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
"SELECT * FROM users WHERE username = $1",
|
||||
name
|
||||
).fetch_one(pool)
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn verify_password(&self, cleartext_password: &str) -> (bool, bool){
|
||||
let Ok(hash) = PasswordHash::new(&self.password) else {
|
||||
error!("invalid password in database for user with pid: {}", self.id);
|
||||
fn generate_nintendo_hash(&self, text_password: &str) -> String{
|
||||
generate_nintendo_hash(self.pid, text_password)
|
||||
}
|
||||
|
||||
if self.password == self.generate_nintendo_hash(cleartext_password){
|
||||
return (true, true)
|
||||
}
|
||||
pub fn verify_cleartext_password(&self, cleartext_password: &str) -> Option<bool>{
|
||||
let nintendo_hash = self.generate_nintendo_hash(cleartext_password);
|
||||
|
||||
return (false, false)
|
||||
};
|
||||
self.verify_hashed_password(cleartext_password)
|
||||
}
|
||||
|
||||
let argon = Argon2::default();
|
||||
|
||||
(argon.verify_password(cleartext_password.as_bytes(), &hash).is_ok(), false)
|
||||
pub fn verify_hashed_password(&self, hashed_password: &str) -> Option<bool>{
|
||||
bcrypt::verify(hashed_password, &self.password).ok()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_password(pid: i32, cleartext_password: &str) -> Option<String>{
|
||||
let password = generate_nintendo_hash(pid, cleartext_password);
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon = Argon2::default();
|
||||
|
||||
let pw = argon.hash_password(password.as_bytes(), &salt).ok()?;
|
||||
|
||||
Some(pw.to_string())
|
||||
bcrypt::hash(password, 10).ok()
|
||||
}
|
||||
|
||||
|
||||
pub async fn read_basic_auth_token(connection: &mut AsyncMysqlConnection, token: &str) -> Option<User> {
|
||||
pub async fn read_basic_auth_token(connection: &Pool, token: &str) -> Option<User> {
|
||||
let data = BASE64_STANDARD.decode(&token).ok()?;
|
||||
|
||||
let decoded_basic_token = String::from_utf8(data).ok()?;
|
||||
|
|
@ -101,28 +104,22 @@ pub async fn read_basic_auth_token(connection: &mut AsyncMysqlConnection, token:
|
|||
|
||||
|
||||
|
||||
let mut user: User = users
|
||||
.filter(name.eq(login_username))
|
||||
.select(User::as_select())
|
||||
.first(connection)
|
||||
.await.ok()?;
|
||||
let mut user = sqlx::query_as!(
|
||||
User,
|
||||
"SELECT * FROM users WHERE username = $1",
|
||||
login_username
|
||||
).fetch_one(connection).await.ok()?;
|
||||
|
||||
let (password_valid, upgrade_password) = user.verify_password(&login_password);
|
||||
|
||||
if password_valid{
|
||||
if upgrade_password{
|
||||
user.password = generate_password(&login_password).unwrap();
|
||||
|
||||
user = connection.update_and_fetch(&user).await.ok()?;
|
||||
}
|
||||
let password_valid = user.verify_cleartext_password(&login_password);
|
||||
|
||||
if password_valid == Some(true){
|
||||
Some(user)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_bearer_auth_token(connection: &mut AsyncMysqlConnection, token: &str) -> Option<User> {
|
||||
async fn read_bearer_auth_token(connection: &Pool, token: &str) -> Option<User> {
|
||||
let data = BASE64_STANDARD.decode(&token).ok()?;
|
||||
|
||||
warn!("bearer token login currently unsupported");
|
||||
|
|
@ -137,15 +134,13 @@ impl<'r> FromRequest<'r> for User{
|
|||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let pool: &Pool = request.rocket().state().unwrap();
|
||||
|
||||
let mut connection = pool.get().await.unwrap();
|
||||
|
||||
let auth = request_try!(request.headers().get("Authorization").next().ok_or(INVALID_TOKEN_ERRORS));
|
||||
|
||||
let (auth_type, token) = request_try!(auth.split_once(' ').ok_or(INVALID_TOKEN_ERRORS));
|
||||
|
||||
let user = match auth_type{
|
||||
"Basic" => read_basic_auth_token(&mut connection, token).await,
|
||||
"Bearer" => read_bearer_auth_token(&mut connection, token).await,
|
||||
"Basic" => read_basic_auth_token(pool, token).await,
|
||||
"Bearer" => read_bearer_auth_token(pool, token).await,
|
||||
_ => return Outcome::Error((Status::BadRequest, INVALID_TOKEN_ERRORS)),
|
||||
};
|
||||
|
||||
|
|
|
|||
5
src/data_wrapper.rs
Normal file
5
src/data_wrapper.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
trait DataWrapper{
|
||||
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ mod nnid;
|
|||
mod account;
|
||||
mod error;
|
||||
mod dsresponse;
|
||||
mod data_wrapper;
|
||||
|
||||
type Pool = sqlx::Pool<Postgres>;
|
||||
|
||||
|
|
@ -42,7 +43,7 @@ async fn launch() -> _ {
|
|||
.to_string()
|
||||
));
|
||||
|
||||
response.adjoin_header(Header::new("Content-Type", "text/xml; charset=utf-8"));
|
||||
//response.adjoin_header(Header::new("Content-Type", "text/xml; charset=utf-8"));
|
||||
|
||||
|
||||
response.remove_header("x-content-type-options");
|
||||
|
|
@ -55,6 +56,8 @@ async fn launch() -> _ {
|
|||
nnid::agreements::get_agreement,
|
||||
nnid::timezones::get_timezone,
|
||||
nnid::person_exists::person_exists,
|
||||
nnid::email::validate
|
||||
nnid::email::validate,
|
||||
nnid::create_account::create_account,
|
||||
nnid::oauth::generate_token::generate_token
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
use chrono::NaiveDate;
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use rocket::{post, State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::account::account::{generate_password, User};
|
||||
use crate::error::Errors;
|
||||
use crate::nnid::pid_distribution::next_pid;
|
||||
use crate::Pool;
|
||||
use crate::xml::{Xml, YesNoVal};
|
||||
|
||||
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Email{
|
||||
pub struct Email{
|
||||
address: Box<str>
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct Mii{
|
||||
pub struct Mii{
|
||||
name: Box<str>,
|
||||
primary: YesNoVal,
|
||||
data: Box<str>,
|
||||
|
|
@ -20,7 +23,7 @@ struct Mii{
|
|||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename(serialize = "person"))]
|
||||
struct AccountCreationData{
|
||||
pub struct AccountCreationData{
|
||||
birth_date: NaiveDate,
|
||||
user_id: Box<str>,
|
||||
password: Box<str>,
|
||||
|
|
@ -28,21 +31,28 @@ struct AccountCreationData{
|
|||
language: Box<str>,
|
||||
tz_name: Box<str>,
|
||||
email: Email,
|
||||
mii: Mii,
|
||||
gender: Box<str>,
|
||||
marketing_flag: YesNoVal,
|
||||
off_device_flag: YesNoVal,
|
||||
region: i32
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename(serialize = "person"))]
|
||||
struct AccountCreationResponseData{
|
||||
pub struct AccountCreationResponseData{
|
||||
pid: i32
|
||||
}
|
||||
|
||||
#[post("/v1/api/people", data="<data>")]
|
||||
async fn create_account(database: &State<Pool>, data: Xml<AccountCreationData>) -> Result<Xml<AccountCreationResponseData>, Errors>{
|
||||
pub async fn create_account(database: &State<Pool>, data: Xml<AccountCreationData>) -> Result<Xml<AccountCreationResponseData>, Option<Errors>>{
|
||||
let database = database.inner();
|
||||
|
||||
// its fine to crash here if we cant get the next pid as that is in my opinion a dead state
|
||||
// anyways as noone can register anymore, EVER
|
||||
|
||||
let pid = next_pid(database).await;
|
||||
|
||||
let AccountCreationData {
|
||||
user_id,
|
||||
password,
|
||||
|
|
@ -52,22 +62,27 @@ async fn create_account(database: &State<Pool>, data: Xml<AccountCreationData>)
|
|||
email: Email{
|
||||
address
|
||||
},
|
||||
mii: Mii{
|
||||
data,
|
||||
..
|
||||
},
|
||||
marketing_flag,
|
||||
gender,
|
||||
region,
|
||||
country,
|
||||
off_device_flag,
|
||||
..
|
||||
} = *data;
|
||||
} = data.0;
|
||||
|
||||
|
||||
let password = generate_password(pid, &password).ok_or(None)?;
|
||||
|
||||
let new_account = sqlx::query("
|
||||
INSERT INTO users.users (
|
||||
sqlx::query!("
|
||||
INSERT INTO users (
|
||||
pid,
|
||||
username,
|
||||
password,
|
||||
birthdate,
|
||||
birthdate,
|
||||
timezone,
|
||||
email,
|
||||
country,
|
||||
|
|
@ -75,26 +90,34 @@ async fn create_account(database: &State<Pool>, data: Xml<AccountCreationData>)
|
|||
marketing_allowed,
|
||||
off_device_allowed,
|
||||
region,
|
||||
gender,
|
||||
mii_data
|
||||
) VALUES (
|
||||
?,?,?,?,?,?,?,?,?,?
|
||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13
|
||||
)
|
||||
");
|
||||
",
|
||||
pid,
|
||||
user_id.as_ref(),
|
||||
password,
|
||||
birth_date,
|
||||
tz_name.as_ref(),
|
||||
address.as_ref(),
|
||||
country.as_ref(),
|
||||
language.as_ref(),
|
||||
marketing_flag.0,
|
||||
off_device_flag.0,
|
||||
region,
|
||||
gender.as_ref(),
|
||||
data.as_ref()
|
||||
).execute(database).await.unwrap();
|
||||
|
||||
|
||||
|
||||
let pid = connection.transaction::<_, diesel::result::Error, _>(|conn| Box::pin(async move{
|
||||
use crate::schema::users::dsl::*;
|
||||
|
||||
diesel::insert_into(users)
|
||||
.values(&new_account)
|
||||
.returning(User::as_returning())
|
||||
.get_result(conn)
|
||||
.await?;
|
||||
|
||||
|
||||
Ok(())
|
||||
})).await;
|
||||
|
||||
Ok(
|
||||
Xml(AccountCreationResponseData{
|
||||
pid
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -3,4 +3,6 @@ pub mod agreements;
|
|||
pub mod timezones;
|
||||
pub mod person_exists;
|
||||
pub mod email;
|
||||
mod create_account;
|
||||
pub mod create_account;
|
||||
pub mod oauth;
|
||||
mod pid_distribution;
|
||||
|
|
|
|||
52
src/nnid/oauth/generate_token.rs
Normal file
52
src/nnid/oauth/generate_token.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
use rocket::{post, FromForm, State};
|
||||
use rocket::form::Form;
|
||||
use serde::Deserialize;
|
||||
use crate::account::account::User;
|
||||
use crate::error::{Error, Errors};
|
||||
use crate::Pool;
|
||||
|
||||
const ACCOUNT_ID_OR_PASSWORD_ERRORS: Errors = Errors{
|
||||
error: &[
|
||||
Error{
|
||||
code: "0106",
|
||||
message: "Invalid account ID or password"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const ACCOUNT_BANNED_ERRORS: Errors = Errors{
|
||||
error: &[
|
||||
Error{
|
||||
code: "0122",
|
||||
message: "Device has been banned by game server"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
#[derive(FromForm)]
|
||||
pub struct TokenRequestData<'a>{
|
||||
grant_type: &'a str,
|
||||
user_id: &'a str,
|
||||
password: &'a str,
|
||||
password_type: &'a str,
|
||||
}
|
||||
|
||||
#[post("/v1/api/oauth20/access_token/generate", data="<data>")]
|
||||
pub async fn generate_token(pool: &State<Pool>, data: Form<TokenRequestData<'_>>) -> Result<(), Option<Errors<'static>>>{
|
||||
let pool = pool.inner();
|
||||
|
||||
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){
|
||||
return Err(Some(ACCOUNT_ID_OR_PASSWORD_ERRORS));
|
||||
}
|
||||
|
||||
if user.account_level < 0{
|
||||
return Err(Some(ACCOUNT_BANNED_ERRORS));
|
||||
}
|
||||
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
1
src/nnid/oauth/mod.rs
Normal file
1
src/nnid/oauth/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod generate_token;
|
||||
|
|
@ -4,16 +4,19 @@ use crate::error::{Error, Errors};
|
|||
use crate::Pool;
|
||||
use crate::xml::Xml;
|
||||
|
||||
|
||||
#[get("/v1/api/people/<username>")]
|
||||
pub async fn person_exists(database: &State<Pool>, username: &str) -> Result<(), Errors<'static>>{
|
||||
let database = database.inner();
|
||||
|
||||
let exists: bool = sqlx::query_as!(
|
||||
bool,
|
||||
"SELECT EXISTS(SELECT 1 FROM users.users WHERE username = ? )",
|
||||
let exists = sqlx::query!(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE username = $1 ) as exists",
|
||||
username
|
||||
).fetch_one(database)
|
||||
.await
|
||||
.ok()
|
||||
.map(|v| v.exists)
|
||||
.flatten()
|
||||
.unwrap_or(true);
|
||||
|
||||
if exists {
|
||||
|
|
|
|||
29
src/nnid/pid_distribution.rs
Normal file
29
src/nnid/pid_distribution.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
use crate::Pool;
|
||||
|
||||
pub async fn next_pid(pool: &Pool) -> i32{
|
||||
loop {
|
||||
let next_pid = sqlx::query!("SELECT nextval('pid_counter') as pid")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("unable to get next pid")
|
||||
.pid
|
||||
.expect("unable to get next pid") as i32;
|
||||
|
||||
let already_exists = sqlx::query!(
|
||||
"SELECT EXISTS(select 1 from users where pid = $1)",
|
||||
next_pid
|
||||
).fetch_one(pool)
|
||||
.await
|
||||
.ok()
|
||||
.map(|v| v.exists)
|
||||
.flatten()
|
||||
.unwrap_or(true);
|
||||
|
||||
if !already_exists{
|
||||
break next_pid;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue