Merge pull request #62 from PretendoNetwork/dev

Merge dev to master
This commit is contained in:
Jonathan Barrow 2023-02-25 23:01:59 -05:00 committed by GitHub
commit 25c7a1aebb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 4852 additions and 2092 deletions

5
.gitignore vendored
View file

@ -60,7 +60,8 @@ typings/
# custom
sign.js
t.js
p.js
config.json
servers.json
certs
/cdn
/cdn
dump.rdb

7
README.md Normal file
View file

@ -0,0 +1,7 @@
# Account server
## What is this?
The account server is a replacement for several account-based services used by the WiiU and 3DS. It replaces the NNID api as well as NASC for the 3DS. It also contains a dedicated PNID api service for getting details of PNIDs outside of the consoles
## Setup
TODO

View file

@ -1,7 +1,7 @@
#!/bin/sh
# this doesnt check game server specific certs, only static file paths
files='config.json certs/nex/datastore/secret.key certs/access/secret.key certs/access/aes.key certs/access/private.pem certs/access/public.pem'
files='config.json'
for file in $files; do
if [ ! -f $file ]; then
@ -10,4 +10,19 @@ for file in $files; do
fi
done
# check for keys
keys='certs/nex/datastore/secret.key certs/service/account/secret.key certs/service/account/aes.key certs/service/account/private.pem certs/service/account/public.pem'
for file in $keys; do
if [ ! -f "$file" ]; then
if [ x"${GENERATE_NEW_KEYS}" = "x" ]; then
echo "$PWD/$file file does not exist. Please mount and try again."
exit 1
else
echo "$PWD/$file file does not exist. Generating a temporary one"
node generate-keys.js nex datastore
node generate-keys.js account
fi
fi
done
exec node src/server.js

40
example.config.json Normal file
View file

@ -0,0 +1,40 @@
{
"http": {
"port": 7070
},
"mongoose": {
"connection_string": "mongodb://localhost:27017/database_name",
"options": {
"useNewUrlParser": true
}
},
"redis": {
"client": {
"url": "redis://localhost:6379"
}
},
"email": {
"host": "smtp.gmail.com",
"port": 587,
"secure": false,
"auth": {
"user": "username",
"pass": "password"
},
"from": "Company Name <user@company.net>"
},
"s3": {
"endpoint": "nyc3.digitaloceanspaces.com",
"key": "ACCESS_KEY",
"secret": "ACCESS_SECRET"
},
"hcaptcha": {
"secret": "0x0000000000000000000000000000000000000000"
},
"cdn": {
"base_url": "https://local-cdn.example.com",
"subdomain": "local-cdn",
"disk_path": "/home/jon/pretend-cdn"
},
"website_base": "https://example.com"
}

20
example.env Normal file
View file

@ -0,0 +1,20 @@
PN_ACT_PREFER_ENV_CONFIG=true # Load config from ENV instead of config.json
PN_ACT_CONFIG_HTTP_PORT=7070
PN_ACT_CONFIG_MONGO_CONNECTION_STRING=mongodb://localhost:27017/database_name
PN_ACT_CONFIG_MONGOOSE_OPTION_useNewUrlParser=true
PN_ACT_CONFIG_MONGOOSE_OPTION_useUnifiedTopology=true
PN_ACT_CONFIG_REDIS_URL=redis://localhost:6379
PN_ACT_CONFIG_EMAIL_HOST=smtp.gmail.com
PN_ACT_CONFIG_EMAIL_PORT=587
PN_ACT_CONFIG_EMAIL_SECURE=false
PN_ACT_CONFIG_EMAIL_USERNAME=username
PN_ACT_CONFIG_EMAIL_PASSWORD=password
PN_ACT_CONFIG_EMAIL_FROM=Company Name <user@company.net>
PN_ACT_CONFIG_S3_ENDPOINT=nyc3.digitaloceanspaces.com
PN_ACT_CONFIG_S3_ACCESS_KEY=ACCESS_KEY
PN_ACT_CONFIG_S3_ACCESS_SECRET=ACCESS_SECRET
PN_ACT_CONFIG_HCAPTCHA_SECRET=0x0000000000000000000000000000000000000000
PN_ACT_CONFIG_CDN_BASE_URL=https://local-cdn.example.com
PN_ACT_CONFIG_CDN_SUBDOMAIN=local-cdn
PN_ACT_CONFIG_CDN_DISK_PATH=/home/jon/pretend-cdn
PN_ACT_CONFIG_WEBSITE_BASE=https://example.com

View file

@ -1,79 +1,134 @@
const NodeRSA = require('node-rsa');
const crypto = require('crypto');
const fs = require('fs-extra');
const yesno = require('yesno');
const logger = require('./logger');
require('colors');
const args = process.argv.slice(2);
const ALLOWED_CHARS_REGEX = /[^a-zA-Z0-9_-]/g;
if (args.length < 1) {
usage();
return;
}
async function main() {
const args = process.argv.slice(2);
const [type, name] = args;
if (!['nex', 'service', 'access'].includes(type)) {
usage();
return;
}
if (type !== 'access' && (!name || name.trim() === '')) {
usage();
return;
}
let path;
if (type === 'access') {
path = `${__dirname}/certs/${type}`;
} else {
path = `${__dirname}/certs/${type}/${name}`;
}
// Ensure the output directories exist
console.log('Creating output directories'.brightGreen);
fs.ensureDirSync(path);
// Generate new AES key
console.log('Generating AES key'.brightGreen);
const aesKey = crypto.randomBytes(16);
// Saving AES key
fs.writeFileSync(`${path}/aes.key`, aesKey.toString('hex'));
console.log(`Saved AES key to file ${path}/aes.key`.brightBlue);
const key = new NodeRSA({ b: 1024}, null, {
environment: 'browser',
encryptionScheme: {
'hash': 'sha256',
if (args.length < 1) {
logger.error('Must pass in type and optional name');
usage();
return;
}
});
// Generate new key pair
console.log('Generating RSA key pair'.brightGreen, '(this may take a while)'.yellow.bold);
key.generateKeyPair(1024);
let [type, name] = args;
// Export the keys
console.log('Exporting public key'.brightGreen);
const publickKey = key.exportKey('public');
type = type.toLowerCase().trim();
// Saving public key
fs.writeFileSync(`${path}/public.pem`, publickKey);
console.log(`Saved public key to file ${path}/public.pem`.brightBlue);
if (name) {
name = name.toLowerCase().trim();
console.log('Exporting private key'.brightGreen);
const privatekKey = key.exportKey('private');
if (ALLOWED_CHARS_REGEX.test(name)) {
logger.error(`Invalid name. Names must only contain [^a-zA-Z0-9_-]. Got ${name}`);
return;
}
}
// Saving private key
fs.writeFileSync(`${path}/private.pem`, privatekKey);
console.log(`Saved public key to file ${path}/private.pem`.brightBlue);
if (!['nex', 'service', 'account'].includes(type)) {
logger.error(`Invalid type. Expected nex, service, or account. Got ${type}`);
usage();
return;
}
// Create HMAC secret key
console.log('Generating HMAC secret'.brightGreen);
const secret = crypto.randomBytes(16);
fs.writeFileSync(`${path}/secret.key`, secret.toString('hex'));
if (type !== 'account' && (!name || name === '')) {
logger.error('If type is not account, a name MUST be passed');
usage();
return;
}
console.log(`Saved HMAC secret to file ${path}/secret.key`.brightBlue);
if (type === 'service' && name === 'account') {
logger.error('Cannot use service name \'account\'. Reserved');
usage();
return;
}
let path;
if (type === 'account') {
path = `${__dirname}/certs/service/account`;
} else {
path = `${__dirname}/certs/${type}/${name}`;
}
if (fs.pathExistsSync(path)) {
const overwrite = await yesno({
question: 'Keys found for type name, overwrite existing keys?'
});
if (!overwrite) {
logger.info('Not overwriting existing keys. Exiting program');
return;
}
}
const publicKeyPath = `${path}/public.pem`;
const privateKeyPath = `${path}/private.pem`;
const aesKeyPath = `${path}/aes.key`;
const secretKeyPath = `${path}/secret.key`;
// Ensure the output directories exist
logger.info('Creating output directories...');
fs.ensureDirSync(path);
logger.success('Created output directories!');
const key = new NodeRSA({ b: 1024 }, null, {
environment: 'browser',
encryptionScheme: {
'hash': 'sha256',
}
});
// Generate new key pair
logger.info('Generating RSA key pair...');
logger.warn('(this may take a while)')
key.generateKeyPair(1024);
logger.success('Generated RSA key pair!');
// Export the keys
logger.info('Exporting public key...');
const publicKey = key.exportKey('public');
logger.success('Exported public key!');
// Saving public key
logger.info('Saving public key to disk...');
fs.writeFileSync(publicKeyPath, publicKey);
logger.success(`Saved public key to ${publicKeyPath}!`);
logger.info('Exporting private key...');
const privateKey = key.exportKey('private');
logger.success('Exported private key!');
// Saving private key
logger.info('Saving private key to disk...');
fs.writeFileSync(privateKeyPath, privateKey);
logger.success(`Saved private key to ${privateKeyPath}!`);
// Generate new AES key
logger.info('Generating AES key...');
const aesKey = crypto.randomBytes(16);
logger.success('Generated AES key!');
// Saving AES key
logger.info('Saving AES key to disk...');
fs.writeFileSync(aesKeyPath, aesKey.toString('hex'));
logger.success(`Saved AES key to ${aesKeyPath}!`);
// Create HMAC secret key
logger.info('Generating HMAC secret...');
const secret = crypto.randomBytes(16);
logger.success('Generated RSA key pair!');
logger.info('Saving HMAC secret to disk...');
fs.writeFileSync(secretKeyPath, secret.toString('hex'));
logger.success(`Saved HMAC secret to ${secretKeyPath}!`);
logger.success('Keys generated successfully');
}
// Display usage information
function usage() {
@ -82,7 +137,9 @@ function usage() {
console.log('Types:');
console.log(' - nex');
console.log(' - service');
console.log(' - access');
console.log(' - account');
console.log('Name: service or nex server name. Not used in access type');
}
console.log('Name: Service or NEX server name. Not used in account type');
}
main().catch(logger.error);

View file

@ -0,0 +1,32 @@
const database = require('../../src/database');
const { NEXAccount } = require('../../src/models/nex-account');
database.connect().then(async function () {
const nexAccounts = await NEXAccount.find({});
const nexAccounts3DS = await NEXAccount.find({ device_type: '3ds' });
const nexAccountsWiiU = await NEXAccount.find({ device_type: 'wiiu' });
console.log('NEX accounts:', nexAccounts.length);
console.log('NEX accounts (3DS):', nexAccounts3DS.length);
console.log('NEX accounts (WiiU):', nexAccountsWiiU.length);
for (const nexAccount of nexAccounts) {
let deviceType = '';
if (nexAccount.owning_pid !== nexAccount.pid) {
// 3DS account
deviceType = '3ds';
} else {
// WiiU account
deviceType = 'wiiu';
}
nexAccount.device_type = deviceType;
await nexAccount.save();
}
console.log('Migrated accounts');
process.exit(0);
});

4188
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -25,6 +25,7 @@
"colors": "^1.4.0",
"cors": "^2.8.5",
"dicer": "^0.2.5",
"dotenv": "^16.0.3",
"email-validator": "^2.0.4",
"express": "^4.17.1",
"express-form-data": "^2.0.17",
@ -35,7 +36,11 @@
"got": "^11.8.2",
"hcaptcha": "^0.1.0",
"image-pixels": "^1.1.1",
"joi": "^17.6.1",
"kaitai-struct": "^0.9.0",
"lodash.get": "^4.4.2",
"lodash.set": "^4.3.2",
"mii-js": "github:PretendoNetwork/mii-js",
"moment": "^2.24.0",
"moment-timezone": "^0.5.27",
"mongoose": "^5.8.3",
@ -43,12 +48,15 @@
"morgan": "^1.9.1",
"multer": "^1.4.3",
"nodemailer": "^6.4.2",
"redis": "^4.3.1",
"tga": "^1.0.4",
"validator": "^13.7.0",
"xmlbuilder": "^13.0.2",
"xmlbuilder2": "0.0.4"
},
"devDependencies": {
"node-rsa": "^1.0.7",
"prompt": "^1.0.0"
"prompt": "^1.0.0",
"yesno": "^0.4.0"
}
}

View file

@ -0,0 +1,203 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd" lang="en">
<html lang="en">
<head>
<meta name="color-scheme" content="light dark">
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
<style>
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;700&display=swap');
:root {
color-scheme: light dark;
supported-color-schemes:light dark;
}
@media (prefers-color-scheme: light) {
body.email-body,
table.centerer,
table.wrapper {
background-color: #FFFFFF !important;
color: #FFFFFF !important;
}
table.card {
background-color: #673DB6 !important;
}
span.shoutout {
color: #D9C6FA !important;
}
td.confirm-link {
background-color: #9D6FF3 !important;
}
td.confirm-code {
background-color: #D9C6FA !important;
color: #45297A !important;
}
td.notice {
color: #9D6FF3 !important;
}
td.notice a {
color: #673DB6 !important;
}
img.logo {
content: url("https://assets.pretendo.cc/images/pretendo-wordmark-singlecolor-purple.png") !important;
}
}
@media (prefers-color-scheme: dark) {
body.email-body,
table.centerer,
table.wrapper {
background-color: #1B1F3B !important;
color: #FFFFFF !important;
}
table.card {
background-color: #23274A !important;
}
span.shoutout {
color: #CAB1FB !important;
}
td.confirm-link {
background-color: #673DB6 !important;
}
td.confirm-code {
background-color: #373C65 !important;
color: #ffffff !important;
}
td.notice {
color: #8990C1 !important;
}
td.notice a {
color: #CAC1F5 !important;
}
}
</style>
</head>
<body class="email-body" bgcolor="#1B1F3B" style="margin-left: 0; margin-right: 0; margin-top: 0; margin-bottom: 0; padding-left: 0; padding-right: 0; padding-top: 0; padding-bottom: 0; font-family: Poppins, Arial, Helvetica, sans-serif;">
<div style="display:none;">Hello {{username}}! Your Pretendo Network ID activation is almost complete. Please click the link in this email to confirm your e-mail address and complete the activation process.</div>
<table class="centerer" bgcolor="#1B1F3B" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td align="center">
<table class="wrapper" bgcolor="#1B1F3B" style="font-family: Poppins, Arial, Helvetica, sans-serif;" border="0" cellpadding="0" cellspacing="0" height="100%" width="420px">
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td width="32px">&nbsp;</td>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td height="36px" style="line-height: 36px;" width="100%">&nbsp;</td>
</tr>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td>
<a href="https://pretendo.network">
<img class="logo" width="auto" height="48px" src="https://assets.pretendo.cc/images/pretendo-wordmark-multicolor-purple+white.png" alt="Pretendo">
</a>
</td>
</tr>
<tr>
<td width="100%" height="36px" style="line-height: 36px;">&nbsp;</td>
</tr>
<tr>
<td>
<table class="card" bgcolor="#23274a" style="color: #ffffff; border-radius: 10px;" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td width="24px" height="100%">&nbsp;</td>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr width="100%" height="48px" style="line-height: 48px;">
<td>&nbsp;</td>
</tr>
<tr style="font-size: 24px; font-weight: 700;">
<td>
Hello <span class="shoutout" style="color: #cab1fb;">{{username}}</span>!
</td>
</tr>
<tr>
<td width="100%" height="24px" style="line-height: 24px;">&nbsp;</td>
</tr>
<tr>
<td style="color: #ffffff; ">
Your Pretendo Network ID activation is almost complete. Please click the link below to confirm your e-mail address and complete the activation process.
</td>
</tr>
<tr>
<td width="100%" height="16px" style="line-height: 16px;">&nbsp;</td>
</tr>
<tr>
<td class="confirm-link" bgcolor="#673db6" style="font-size: 14px; font-weight: 700; border-radius: 10px; padding: 12px" align="center">
<a href="{{confirmation-href}}" style="text-decoration: none; color: #ffffff; " width="100%">
Confirm email address
</a>
</td>
</tr>
<tr>
<td width="100%" height="48px" style="line-height: 48px;">&nbsp;</td>
</tr>
<tr>
<td>
You may also enter the following 6-digit code on your console:
</td>
</tr>
<tr>
<td width="100%" height="16px" style="line-height: 16px;">&nbsp;</td>
</tr>
<tr>
<td class="confirm-code" bgcolor="#373c65" style="color: #ffffff; font-size: 14px; font-weight: 700; border-radius: 10px; padding: 12px" align="center">
{{confirmation-code}}
</td>
</tr>
<tr>
<td width="100%" height="48px" style="line-height: 48px;">&nbsp;</td>
</tr>
<tr>
<td>
We hope you have fun using our services!
</td>
</tr>
<tr>
<td width="100%" height="36px" style="line-height: 36px;">&nbsp;</td>
</tr>
<tr>
<td align="right">
The Pretendo Network team
</td>
</tr>
<tr>
<td width="100%" height="24px" style="line-height: 24px;">&nbsp;</td>
</tr>
</table>
</td>
<td width="24px" height="100%">&nbsp;</td>
</tr>
</table>
</td>
</tr>
<tr>
<td width="100%" height="18px" style="line-height: 18px;">&nbsp;</td>
</tr>
<tr>
<td class="notice" style="color: #8990c1; font-size: 12px;">
Note: this email message was auto-generated, please do not respond. For further assistance, please join our <a href="https://invite.gg/pretendo" style="text-decoration: none; color: #ffffff; ">Discord server</a>.
</td>
</tr>
<tr>
<td width="100%" height="48px" style="line-height: 48px;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
<td width="32px">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,170 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd" lang="en">
<html lang="en">
<head>
<meta name="color-scheme" content="light dark">
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
<style>
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;700&display=swap');
:root {
color-scheme: light dark;
supported-color-schemes:light dark;
}
@media (prefers-color-scheme: light) {
body.email-body,
table.centerer,
table.wrapper {
background-color: #FFFFFF !important;
color: #FFFFFF !important;
}
table.card {
background-color: #673DB6 !important;
}
span.shoutout {
color: #D9C6FA !important;
}
td.confirm-link {
background-color: #9D6FF3 !important;
}
td.confirm-code {
background-color: #D9C6FA !important;
color: #45297A !important;
}
td.notice {
color: #9D6FF3 !important;
}
td.notice a {
color: #673DB6 !important;
}
img.logo {
content: url("https://assets.pretendo.cc/images/pretendo-wordmark-singlecolor-purple.png") !important;
}
}
@media (prefers-color-scheme: dark) {
body.email-body,
table.centerer,
table.wrapper {
background-color: #1B1F3B !important;
color: #FFFFFF !important;
}
table.card {
background-color: #23274A !important;
}
span.shoutout {
color: #CAB1FB !important;
}
td.confirm-link {
background-color: #673DB6 !important;
}
td.confirm-code {
background-color: #373C65 !important;
color: #ffffff !important;
}
td.notice {
color: #8990C1 !important;
}
td.notice a {
color: #CAC1F5 !important;
}
}
</style>
</head>
<body class="email-body" bgcolor="#1B1F3B" style="margin-left: 0; margin-right: 0; margin-top: 0; margin-bottom: 0; padding-left: 0; padding-right: 0; padding-top: 0; padding-bottom: 0; font-family: Poppins, Arial, Helvetica, sans-serif;">
<div style="display:none;">{{preview}}</div>
<table class="centerer" bgcolor="#1B1F3B" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td align="center">
<table class="wrapper" bgcolor="#1B1F3B" style="font-family: Poppins, Arial, Helvetica, sans-serif;" border="0" cellpadding="0" cellspacing="0" height="100%" width="420px">
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td width="32px">&nbsp;</td>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td height="36px" style="line-height: 36px;" width="100%">&nbsp;</td>
</tr>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td>
<a href="https://pretendo.network">
<img class="logo" width="auto" height="48px" src="https://assets.pretendo.cc/images/pretendo-wordmark-multicolor-purple+white.png" alt="Pretendo">
</a>
</td>
</tr>
<tr>
<td width="100%" height="36px" style="line-height: 36px;">&nbsp;</td>
</tr>
<tr>
<td>
<table class="card" bgcolor="#23274a" style="color: #ffffff; border-radius: 10px;" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td width="24px" height="100%">&nbsp;</td>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr width="100%" height="48px" style="line-height: 48px;">
<td>&nbsp;</td>
</tr>
<tr style="font-size: 24px; font-weight: 700;">
<td>
Dear <span class="shoutout" style="color: #cab1fb;">{{username}}</span>,
</td>
</tr>
<tr>
<td width="100%" height="24px" style="line-height: 24px;">&nbsp;</td>
</tr>
<tr>
<td style="color: #ffffff; ">
{{paragraph}}
</td>
</tr>
<!--{{buttonPlaceholder}}-->
<tr>
<td width="100%" height="36px" style="line-height: 36px;">&nbsp;</td>
</tr>
<tr>
<td align="right">
The Pretendo Network team
</td>
</tr>
<tr>
<td width="100%" height="24px" style="line-height: 24px;">&nbsp;</td>
</tr>
</table>
</td>
<td width="24px" height="100%">&nbsp;</td>
</tr>
</table>
</td>
</tr>
<tr>
<td width="100%" height="18px" style="line-height: 18px;">&nbsp;</td>
</tr>
<tr>
<td class="notice" style="color: #8990c1; font-size: 12px;">
Note: this email message was auto-generated, please do not respond. For further assistance, please join our <a href="https://invite.gg/pretendo" style="text-decoration: none; color: #ffffff; ">Discord server</a>.
</td>
</tr>
<tr>
<td width="100%" height="48px" style="line-height: 48px;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
<td width="32px">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

212
src/cache.js Normal file
View file

@ -0,0 +1,212 @@
const fs = require('fs-extra');
const redis = require('redis');
const { config, disabledFeatures } = require('./config-manager');
let client;
const memoryCache = {};
const SERVICE_CERTS_BASE = `${__dirname}/../certs/service`;
const NEX_CERTS_BASE = `${__dirname}/../certs/nex`;
const LOCAL_CDN_BASE = `${__dirname}/../cdn`;
async function connect() {
if (!disabledFeatures.redis) {
client = redis.createClient(config.redis.client);
client.on('error', (err) => console.log('Redis Client Error', err));
await client.connect();
}
}
async function setCachedFile(fileName, value) {
if (disabledFeatures.redis) {
memoryCache[fileName] = value;
} else {
await client.set(fileName, value);
}
}
async function getCachedFile(fileName, encoding) {
let cachedFile;
if (disabledFeatures.redis) {
cachedFile = memoryCache[fileName] || null;
} else {
cachedFile = await client.get(fileName);
}
if (cachedFile !== null) {
cachedFile = Buffer.from(cachedFile, encoding);
}
return cachedFile;
}
// NEX server cache functions
async function getNEXPublicKey(name, encoding) {
let publicKey = await getCachedFile(`nex:${name}:public_key`, encoding);
if (publicKey === null) {
publicKey = await fs.readFile(`${NEX_CERTS_BASE}/${name}/public.pem`, { encoding });
await setNEXPublicKey(name, publicKey);
}
return publicKey;
}
async function getNEXPrivateKey(name, encoding) {
let privateKey = await getCachedFile(`nex:${name}:private_key`, encoding);
if (privateKey === null) {
privateKey = await fs.readFile(`${NEX_CERTS_BASE}/${name}/private.pem`, { encoding });
await setNEXPrivateKey(name, privateKey);
}
return privateKey;
}
async function getNEXSecretKey(name, encoding) {
let secretKey = await getCachedFile(`nex:${name}:secret_key`, encoding);
if (secretKey === null) {
const fileBuffer = await fs.readFile(`${NEX_CERTS_BASE}/${name}/secret.key`, { encoding: 'utf8' });
secretKey = Buffer.from(fileBuffer, encoding);
await setNEXSecretKey(name, secretKey);
}
return secretKey;
}
async function getNEXAESKey(name, encoding) {
let aesKey = await getCachedFile(`nex:${name}:aes_key`, encoding);
if (aesKey === null) {
const fileBuffer = await fs.readFile(`${NEX_CERTS_BASE}/${name}/aes.key`, { encoding: 'utf8' });
aesKey = Buffer.from(fileBuffer, encoding);
await setNEXAESKey(name, aesKey);
}
return aesKey;
}
async function setNEXPublicKey(name, value) {
await setCachedFile(`nex:${name}:public_key`, value);
}
async function setNEXPrivateKey(name, value) {
await setCachedFile(`nex:${name}:private_key`, value);
}
async function setNEXSecretKey(name, value) {
await setCachedFile(`nex:${name}:secret_key`, value);
}
async function setNEXAESKey(name, value) {
await setCachedFile(`nex:${name}:aes_key`, value);
}
// 3rd party service cache functions
async function getServicePublicKey(name, encoding) {
let publicKey = await getCachedFile(`service:${name}:public_key`, encoding);
if (publicKey === null) {
publicKey = await fs.readFile(`${SERVICE_CERTS_BASE}/${name}/public.pem`, { encoding });
await setServicePublicKey(name, publicKey);
}
return publicKey;
}
async function getServicePrivateKey(name, encoding) {
let privateKey = await getCachedFile(`service:${name}:private_key`, encoding);
if (privateKey === null) {
privateKey = await fs.readFile(`${SERVICE_CERTS_BASE}/${name}/private.pem`, { encoding });
await setServicePrivateKey(name, privateKey);
}
return privateKey;
}
async function getServiceSecretKey(name, encoding) {
let secretKey = await getCachedFile(`service:${name}:secret_key`, encoding);
if (secretKey === null) {
const fileBuffer = await fs.readFile(`${SERVICE_CERTS_BASE}/${name}/secret.key`, { encoding: 'utf8' });
secretKey = Buffer.from(fileBuffer, encoding);
await setServiceSecretKey(name, secretKey);
}
return secretKey;
}
async function getServiceAESKey(name, encoding) {
let aesKey = await getCachedFile(`service:${name}:aes_key`, encoding);
if (aesKey === null) {
const fileBuffer = await fs.readFile(`${SERVICE_CERTS_BASE}/${name}/aes.key`, { encoding: 'utf8' });
aesKey = Buffer.from(fileBuffer, encoding);
await setServiceAESKey(name, aesKey);
}
return aesKey;
}
async function setServicePublicKey(name, value) {
await setCachedFile(`service:${name}:public_key`, value);
}
async function setServicePrivateKey(name, value) {
await setCachedFile(`service:${name}:private_key`, value);
}
async function setServiceSecretKey(name, value) {
await setCachedFile(`service:${name}:secret_key`, value);
}
async function setServiceAESKey(name, value) {
await setCachedFile(`service:${name}:aes_key`, value);
}
// Local CDN cache functions
async function getLocalCDNFile(name, encoding) {
let file = await getCachedFile(`local_cdn:${name}`, encoding);
if (file === null) {
if (await fs.pathExists(`${LOCAL_CDN_BASE}/${name}`)) {
file = await fs.readFile(`${LOCAL_CDN_BASE}/${name}`, { encoding });
await setLocalCDNFile(name, file);
}
}
return file;
}
async function setLocalCDNFile(name, value) {
await setCachedFile(`local_cdn:${name}`, value);
}
module.exports = {
connect,
getNEXPublicKey,
getNEXPrivateKey,
getNEXSecretKey,
getNEXAESKey,
setNEXPublicKey,
setNEXPrivateKey,
setNEXSecretKey,
setNEXAESKey,
getServicePublicKey,
getServicePrivateKey,
getServiceSecretKey,
getServiceAESKey,
setServicePublicKey,
setServicePrivateKey,
setServiceSecretKey,
setServiceAESKey,
getLocalCDNFile,
setLocalCDNFile
};

335
src/config-manager.js Normal file
View file

@ -0,0 +1,335 @@
const fs = require('fs-extra');
const get = require('lodash.get');
const set = require('lodash.set');
const logger = require('../logger');
require('dotenv').config();
/**
* @typedef {Object} Config
* @property {object} http HTTP server settings
* @property {number} http.port HTTP port the server will listen on
* @property {object} mongoose Mongose connection settings
* @property {string} mongoose.connection_string MongoDB connection string
* @property {object} mongoose.options MongoDB connection options
* @property {object} [redis] redis settings
* @property {string} [redis.client] redis client settings
* @property {string} [redis.client.url] redis server URL
* @property {object} [email] node-mailer client settings
* @property {string} [email.host] SMTP server address
* @property {number} [email.port] SMTP server port
* @property {boolean} [email.secure] Secure SMTP
* @property {string} [email.from] Email 'from' name/address
* @property {object} [email.auth] Email authentication settings
* @property {string} [email.auth.user] Email username
* @property {string} [email.auth.pass] Email password
* @property {object} [s3] s3 client settings
* @property {object} [s3.endpoint] s3 endpoint URL
* @property {string} [s3.key] s3 access key
* @property {string} [s3.secret] s3 access secret
* @property {object} [hcaptcha] hCaptcha settings
* @property {string} [hcaptcha.secret] hCaptcha secret
* @property {object} cdn CDN config settings
* @property {object} [cdn.subdomain] Subdomain used for serving CDN contents when s3 is disabled
* @property {string} [cdn.disk_path] Fully qualified file system path for storing and reading local CDN contents
* @property {string} cdn.base_url Base URL for CDN server
* @property {string} website_base Base URL for service website (used with emails)
*/
/**
* @type {Config}
*/
let config = {};
/**
* @typedef {Object} DisabledFeatures
* @property {boolean} redis true if redis is disabled
* @property {boolean} email true if email sending is disabled
* @property {boolean} captcha true if captcha verification is disabled
* @property {boolean} s3 true if s3 services is disabled
*/
/**
* @type {DisabledFeatures}
*/
const disabledFeatures = {
redis: false,
email: false,
captcha: false,
s3: false
};
const requiredFields = [
['http.port', 'PN_ACT_CONFIG_HTTP_PORT', Number],
['mongoose.connection_string', 'PN_ACT_CONFIG_MONGO_CONNECTION_STRING'],
['cdn.base_url', 'PN_ACT_CONFIG_CDN_BASE_URL']
];
function configure() {
const usingEnv = process.env.PN_ACT_PREFER_ENV_CONFIG === 'true';
if (usingEnv) {
logger.info('Loading config from environment variable');
config = {
http: {
port: Number(process.env.PN_ACT_CONFIG_HTTP_PORT)
},
mongoose: {
connection_string: process.env.PN_ACT_CONFIG_MONGO_CONNECTION_STRING,
options: Object.keys(process.env)
.filter(key => key.startsWith('PN_ACT_CONFIG_MONGOOSE_OPTION_'))
.reduce((obj, key) => {
obj[key.split('_').pop()] = process.env[key];
return obj;
}, {})
},
redis: {
client: {
url: process.env.PN_ACT_CONFIG_REDIS_URL
}
},
email: {
host: process.env.PN_ACT_CONFIG_EMAIL_HOST,
port: Number(process.env.PN_ACT_CONFIG_EMAIL_PORT),
secure: Boolean(process.env.PN_ACT_CONFIG_EMAIL_SECURE),
auth: {
user: process.env.PN_ACT_CONFIG_EMAIL_USERNAME,
pass: process.env.PN_ACT_CONFIG_EMAIL_PASSWORD
},
from: process.env.PN_ACT_CONFIG_EMAIL_FROM
},
s3: {
endpoint: process.env.PN_ACT_CONFIG_S3_ENDPOINT,
key: process.env.PN_ACT_CONFIG_S3_ACCESS_KEY,
secret: process.env.PN_ACT_CONFIG_S3_ACCESS_SECRET
},
hcaptcha: {
secret: process.env.PN_ACT_CONFIG_HCAPTCHA_SECRET
},
cdn: {
subdomain: process.env.PN_ACT_CONFIG_CDN_SUBDOMAIN,
disk_path: process.env.PN_ACT_CONFIG_CDN_DISK_PATH,
base_url: process.env.PN_ACT_CONFIG_CDN_BASE_URL
},
website_base: process.env.PN_ACT_CONFIG_WEBSITE_BASE
};
} else {
logger.info('Loading config from config.json');
if (!fs.pathExistsSync(`${__dirname}/../config.json`)) {
logger.error('Failed to locate config.json file');
process.exit(0);
}
config = require(`${__dirname}/../config.json`);
}
logger.info('Config loaded, checking integrity');
// * Check for required settings
for (const requiredField of requiredFields) {
const [keyPath, env, convertType] = requiredField;
const configValue = get(config, keyPath);
const envValue = get(process.env, keyPath);
if (!configValue || (typeof configValue === 'string' && configValue.trim() === '')) {
if (!envValue || envValue.trim() === '') {
logger.error(`Failed to locate required field ${keyPath}. Set ${keyPath} in config.json or the ${env} environment variable`);
process.exit(0);
} else {
logger.info(`${keyPath} not found in config, using environment variable ${env}`);
const newValue = envValue;
set(config, keyPath, convertType ? convertType(newValue) : newValue);
}
}
}
// * Check for optional settings
const redisCheck = get(config, 'redis.client.url');
if (!redisCheck || redisCheck.trim() === '') {
if (usingEnv) {
logger.warn('Failed to find Redis connection url. Disabling feature and using in-memory cache. To enable feature set the PN_ACT_CONFIG_REDIS_URL environment variable');
} else {
logger.warn('Failed to find Redis connection url. Disabling feature and using in-memory cache. To enable feature set redis.client.url in your config.json');
}
disabledFeatures.redis = true;
}
const emailHostCheck = get(config, 'email.host');
if (!emailHostCheck || emailHostCheck.trim() === '') {
if (usingEnv) {
logger.warn('Failed to find email SMTP host. Disabling feature. To enable feature set the PN_ACT_CONFIG_EMAIL_HOST environment variable');
} else {
logger.warn('Failed to find email SMTP host. Disabling feature. To enable feature set email.host in your config.json');
}
disabledFeatures.email = true;
}
const emailPortCheck = get(config, 'email.port');
if (!emailPortCheck) {
if (usingEnv) {
logger.warn('Failed to find email SMTP port. Disabling feature. To enable feature set the PN_ACT_CONFIG_EMAIL_PORT environment variable');
} else {
logger.warn('Failed to find email SMTP port. Disabling feature. To enable feature set email.port in your config.json');
}
disabledFeatures.email = true;
}
const emailSecureCheck = get(config, 'email.secure');
if (emailSecureCheck === undefined) {
if (usingEnv) {
logger.warn('Failed to find email SMTP secure flag. Disabling feature. To enable feature set the PN_ACT_CONFIG_EMAIL_SECURE environment variable');
} else {
logger.warn('Failed to find email SMTP secure flag. Disabling feature. To enable feature set email.secure in your config.json');
}
disabledFeatures.email = true;
}
const emailUsernameCheck = get(config, 'email.auth.user');
if (!emailUsernameCheck || emailUsernameCheck.trim() === '') {
if (usingEnv) {
logger.warn('Failed to find email account username. Disabling feature. To enable feature set the auth.user environment variable');
} else {
logger.warn('Failed to find email account username. Disabling feature. To enable feature set email.auth.user in your config.json');
}
disabledFeatures.email = true;
}
const emailPasswordCheck = get(config, 'email.auth.pass');
if (!emailPasswordCheck || emailPasswordCheck.trim() === '') {
if (usingEnv) {
logger.warn('Failed to find email account password. Disabling feature. To enable feature set the PN_ACT_CONFIG_EMAIL_PASSWORD environment variable');
} else {
logger.warn('Failed to find email account password. Disabling feature. To enable feature set email.auth.pass in your config.json');
}
disabledFeatures.email = true;
}
const emailFromCheck = get(config, 'email.from');
if (!emailFromCheck || emailFromCheck.trim() === '') {
if (usingEnv) {
logger.warn('Failed to find email from config. Disabling feature. To enable feature set the PN_ACT_CONFIG_EMAIL_FROM environment variable');
} else {
logger.warn('Failed to find email from config. Disabling feature. To enable feature set email.from in your config.json');
}
disabledFeatures.email = true;
}
if (!disabledFeatures.email) {
const websiteBaseCheck = get(config, 'website_base');
if (!websiteBaseCheck || websiteBaseCheck.trim() === '') {
if (usingEnv) {
logger.error('Email sending is enabled and no website base was configured. Set the PN_ACT_CONFIG_WEBSITE_BASE environment variable');
} else {
logger.error('Email sending is enabled and no website base was configured. Set website_base in your config.json');
}
process.exit(0);
}
}
const captchaSecretCheck = get(config, 'hcaptcha.secret');
if (!captchaSecretCheck || captchaSecretCheck.trim() === '') {
if (usingEnv) {
logger.warn('Failed to find captcha secret config. Disabling feature. To enable feature set the PN_ACT_CONFIG_HCAPTCHA_SECRET environment variable');
} else {
logger.warn('Failed to find captcha secret config. Disabling feature. To enable feature set hcaptcha.secret in your config.json');
}
disabledFeatures.captcha = true;
}
const s3EndpointCheck = get(config, 's3.endpoint');
if (!s3EndpointCheck || s3EndpointCheck.trim() === '') {
if (usingEnv) {
logger.warn('Failed to find s3 endpoint config. Disabling feature. To enable feature set the PN_ACT_CONFIG_S3_ENDPOINT environment variable');
} else {
logger.warn('Failed to find s3 endpoint config. Disabling feature. To enable feature set s3.endpoint in your config.json');
}
disabledFeatures.s3 = true;
} else {
}
const s3AccessKeyCheck = get(config, 's3.key');
if (!s3AccessKeyCheck || s3AccessKeyCheck.trim() === '') {
if (usingEnv) {
logger.warn('Failed to find s3 access key config. Disabling feature. To enable feature set the PN_ACT_CONFIG_S3_ACCESS_KEY environment variable');
} else {
logger.warn('Failed to find s3 access key config. Disabling feature. To enable feature set s3.key in your config.json');
}
disabledFeatures.s3 = true;
}
const s3SecretKeyCheck = get(config, 's3.secret');
if (!s3SecretKeyCheck || s3SecretKeyCheck.trim() === '') {
if (usingEnv) {
logger.warn('Failed to find s3 secret key config. Disabling feature. To enable feature set the PN_ACT_CONFIG_S3_ACCESS_SECRET environment variable');
} else {
logger.warn('Failed to find s3 secret key config. Disabling feature. To enable feature set s3.secret in your config.json');
}
disabledFeatures.s3 = true;
}
if (disabledFeatures.s3) {
const cdnSubdomainCheck = get(config, 'cdn.subdomain');
if (!cdnSubdomainCheck || cdnSubdomainCheck.trim() === '') {
if (usingEnv) {
logger.error('s3 file storage is disabled and no CDN subdomain was set. Set the PN_ACT_CONFIG_CDN_SUBDOMAIN environment variable');
} else {
logger.error('s3 file storage is disabled and no CDN subdomain was set. Set cdn.subdomain in your config.json');
}
process.exit(0);
}
if (disabledFeatures.redis) {
logger.warn('Both s3 and Redis are disabled. Large CDN files will use the in-memory cache, which may result in high memory use. Please enable s3 if you\'re running a production server.');
}
logger.warn(`s3 file storage disabled. Using disk-based file storage. Please ensure cdn.base_url config or PN_ACT_CONFIG_CDN_BASE env variable is set to point to this server with the subdomain being ${config.cdn.subdomain}`);
}
module.exports.config = config;
}
module.exports = {
configure,
config,
disabledFeatures
};

View file

@ -1,18 +1,27 @@
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const joi = require('joi');
const util = require('./util');
const { PNID } = require('./models/pnid');
const { Server } = require('./models/server');
const { mongoose: mongooseConfig } = require('../config.json');
const { uri, database, options } = mongooseConfig;
const logger = require('../logger');
const { config } = require('./config-manager');
const { connection_string, options } = config.mongoose;
// TODO: Extend this later with more settings
const discordConnectionSchema = joi.object({
id: joi.string()
});
let connection;
async function connect() {
await mongoose.connect(`${uri}/${database}`, options);
await mongoose.connect(connection_string, options);
connection = mongoose.connection;
connection.on('error', console.error.bind(console, 'connection error:'));
module.exports.connection = connection;
}
function verifyConnected() {
@ -45,17 +54,29 @@ async function getUserByPID(pid) {
return user;
}
async function getUserByEmailAddress(email) {
verifyConnected();
const user = await PNID.findOne({
'email.address': new RegExp(email, 'i') // * Ignore case
});
return user;
}
async function doesUserExist(username) {
verifyConnected();
return !!await this.getUserByUsername(username);
return !!await getUserByUsername(username);
}
async function getUserBasic(token) {
verifyConnected();
// Wii U sends Basic auth as `username password`, where the password may not have spaces
// This is not to spec, but that is the consoles fault not ours
const [username, password] = Buffer.from(token, 'base64').toString().split(' ');
const user = await this.getUserByUsername(username);
const user = await getUserByUsername(username);
if (!user) {
return null;
@ -73,26 +94,32 @@ async function getUserBasic(token) {
async function getUserBearer(token) {
verifyConnected();
const decryptedToken = util.decryptToken(Buffer.from(token, 'base64'));
const unpackedToken = util.unpackToken(decryptedToken);
try {
const decryptedToken = await util.decryptToken(Buffer.from(token, 'base64'));
const unpackedToken = util.unpackToken(decryptedToken);
const user = await getUserByPID(unpackedToken.pid);
const user = await getUserByPID(unpackedToken.pid);
if (user) {
const expireTime = Math.floor((Number(unpackedToken.expire_time) / 1000));
if (user) {
const expireTime = Math.floor((Number(unpackedToken.expire_time) / 1000));
if (Math.floor(Date.now() / 1000) > expireTime) {
return null;
if (Math.floor(Date.now() / 1000) > expireTime) {
return null;
}
}
}
return user;
return user;
} catch (error) {
// TODO: Handle error
logger.error(error);
return null;
}
}
async function getUserProfileJSONByPID(pid) {
verifyConnected();
const user = await this.getUserByPID(pid);
const user = await getUserByPID(pid);
const device = user.get('devices')[0]; // Just grab the first device
let device_attributes;
@ -102,7 +129,7 @@ async function getUserProfileJSONByPID(pid) {
name,
value
};
if (created_date) {
deviceAttributeDocument.created_date = created_date;
}
@ -113,7 +140,7 @@ async function getUserProfileJSONByPID(pid) {
});
}
return {
const userObject = {
//accounts: {}, We need to figure this out, no idea what these values mean or what they do
active_flag: user.get('flags.active') ? 'Y' : 'N',
birth_date: user.get('birthdate'),
@ -133,9 +160,8 @@ async function getUserProfileJSONByPID(pid) {
primary: user.get('email.primary') ? 'Y' : 'N',
reachable: user.get('email.reachable') ? 'Y' : 'N',
type: 'DEFAULT',
updated_by: 'INTERNAL WS', // Can also be INTERNAL WS, don't know the difference
validated: user.get('email.validated') ? 'Y' : 'N',
//validated_date: user.get('email.validated_date') // not used atm
updated_by: 'USER', // Can also be INTERNAL WS, don't know the difference
validated: user.get('email.validated') ? 'Y' : 'N'
},
mii: {
status: 'COMPLETED',
@ -146,9 +172,9 @@ async function getUserProfileJSONByPID(pid) {
mii_image: {
// Images MUST be loaded over HTTPS or console ignores them
// Bunny CDN is the only CDN which seems to support TLS 1.0/1.1 (required)
cached_url: `https://pretendo-cdn.b-cdn.net/mii/${user.pid}/standard.tga`,
cached_url: `${config.cdn.base_url}/mii/${user.pid}/standard.tga`,
id: user.get('mii.image_id'),
url: `https://pretendo-cdn.b-cdn.net/mii/${user.pid}/standard.tga`,
url: `${config.cdn.base_url}/mii/${user.pid}/standard.tga`,
type: 'standard'
}
},
@ -160,6 +186,12 @@ async function getUserProfileJSONByPID(pid) {
user_id: user.get('username'),
utc_offset: user.get('timezone.offset')
};
if (user.get('email.validated')) {
userObject.email.validated_date = user.get('email.validated_date');
}
return userObject;
}
function getServer(gameServerId, accessMode) {
@ -183,7 +215,9 @@ async function addUserConnection(pnid, data, type) {
}
async function addUserConnectionDiscord(pnid, data) {
if (!data.id) {
const valid = discordConnectionSchema.validate(data);
if (valid.error) {
return {
app: 'api',
status: 400,
@ -193,7 +227,7 @@ async function addUserConnectionDiscord(pnid, data) {
await PNID.updateOne({ pid: pnid.get('pid') }, {
$set: {
'connections.discord': data
'connections.discord.id': data.id
}
});
@ -225,8 +259,10 @@ async function removeUserConnectionDiscord(pnid) {
module.exports = {
connect,
connection,
getUserByUsername,
getUserByPID,
getUserByEmailAddress,
doesUserExist,
getUserBasic,
getUserBearer,

View file

@ -1,28 +0,0 @@
{
"http": {
"port": 7070
},
"mongoose": {
"uri": "mongodb://localhost:27017",
"database": "database_name",
"options": {
"useNewUrlParser": true
}
},
"email": {
"service": "gmail",
"auth": {
"user": "email@address.com",
"pass": "password"
}
},
"aws": {
"spaces": {
"key": "ACCESS_KEY",
"secret": "ACCESS_SECRET"
}
},
"hcaptcha": {
"secret": "0x0000000000000000000000000000000000000000"
}
}

View file

@ -1,24 +0,0 @@
[
{
"title_ids": [
"000500301001500A",
"000500301001510A",
"000500301001520A",
"0005001010001C00"
],
"server_id": "00003200",
"ip": "192.168.0.27",
"port": "60000",
"system": 1,
"crypto": {
"service": {
"cek": "",
"hmac_secret": ""
},
"nex": {
"cek": "",
"hmac_secret": ""
}
}
}
]

View file

@ -1,33 +1,64 @@
const nodemailer = require('nodemailer');
const config = require('../config.json');
const { config, disabledFeatures } = require('./config-manager');
const path = require('path');
const fs = require("fs");
const genericEmailTemplate = fs.readFileSync(path.join(__dirname, './assets/emails/genericTemplate.html'), 'utf8');
const confirmationEmailTemplate = fs.readFileSync(path.join(__dirname, './assets/emails/confirmationTemplate.html'), 'utf8');
const transporter = nodemailer.createTransport(config.email);
let transporter;
if (!disabledFeatures.email) {
transporter = nodemailer.createTransport(config.email);
}
/**
* Sends an email with the specified subject and message to an email address
* @param {String} email the destination email address
* @param {String} subject The Subject of the email
* @param {String} message The body of the email
*/
async function send(email, subject = 'No email subject provided', message = 'No email body provided') {
const options = {
from: config.email.auth.user,
to: email,
subject: subject,
html: message
};
@param {Object} options
@param {String} options.to The address to send the email to
@param {String} options.subject The subject of the email
return new Promise(resolve => {
transporter.sendMail(options, (error) => {
if (error) {
console.warn(error);
}
@param {String} options.username The username of the user (shown in the greeting)
@param {String} options.preview The preview text of the email (shown in the inbox by the email client)
@param {String} options.text The text version of the email
return resolve();
@param {String} options.paragraph The main content of the email
@param {Object} options.confirmation Whether or not the email is a confirmation email
@param {String} options.confirmation.href The link to the confirmation page
@param {String} options.confirmation.code The confirmation code
@param {Object} options.link An object containing the link to be shown in the email
@param {String} options.link.href The URL of the link
@param {String} options.link.text The text of the link
*/
async function sendMail(options) {
if (!disabledFeatures.email) {
const { to, subject, username, paragraph, preview, text, link, confirmation } = options;
let html = confirmation ? confirmationEmailTemplate : genericEmailTemplate;
html = html.replace(/{{username}}/g, username);
html = html.replace(/{{paragraph}}/g, paragraph);
html = html.replace(/{{preview}}/g, (preview || ""));
html = html.replace(/{{confirmation-href}}/g, (confirmation?.href || ""));
html = html.replace(/{{confirmation-code}}/g, (confirmation?.code || ""));
if (link) {
const { href, text } = link;
const button = `<tr><td width="100%" height="16px" style="line-height: 16px;">&nbsp;</td></tr><tr><td class="confirm-link" bgcolor="#673db6" style="font-size: 14px; font-weight: 700; border-radius: 10px; padding: 12px" align="center"><a href="${href}" style="text-decoration: none; color: #ffffff; " width="100%">${text}</a></td></tr>`
html = html.replace(/<!--{{buttonPlaceholder}}-->/g, button);
}
await transporter.sendMail({
to,
subject,
text,
html
});
});
}
}
module.exports = {
send: send
};
sendMail
};

View file

@ -2,6 +2,7 @@ const crypto = require('crypto');
const { Device } = require('../models/device');
const { NEXAccount } = require('../models/nex-account');
const util = require('../util');
const database = require('../database');
const NintendoCertificate = require('../nintendo-certificate');
async function NASCMiddleware(request, response, next) {
@ -109,36 +110,53 @@ async function NASCMiddleware(request, response, next) {
if (password && !pid && !pidHmac) {
// Register new user
// Create new NEX account
const newNEXAccount = new NEXAccount({
pid: 0,
password: '',
owning_pid: 0,
});
await newNEXAccount.save();
const session = await database.connection.startSession();
await session.startTransaction();
pid = newNEXAccount.get('pid');
try {
// Create new NEX account
const nexAccount = await new NEXAccount({
device_type: '3ds',
password
});
// Set password
await NEXAccount.updateOne({ pid }, { password });
await nexAccount.generatePID();
if (!device) {
const deviceDocument = {
is_emulator: false,
model,
serial: serialNumber,
environment,
mac_hash: macAddressHash,
fcdcert_hash: fcdcertHash,
linked_pids: [pid]
};
device = new Device(deviceDocument);
} else {
device.linked_pids.push(pid);
await nexAccount.save({ session });
pid = nexAccount.get('pid');
// Set password
if (!device) {
device = new Device({
is_emulator: false,
model,
serial: serialNumber,
environment,
mac_hash: macAddressHash,
fcdcert_hash: fcdcertHash,
linked_pids: [pid]
});
} else {
device.linked_pids.push(pid);
}
await device.save({ session });
await session.commitTransaction();
} catch (error) {
logger.error('[NASC] REGISTER ACCOUNT: ' + error);
await session.abortTransaction();
// 3DS expects 200 even on error
return response.status(200).send(util.nascError('102'));
} finally {
// * This runs regardless of failure
// * Returning on catch will not prevent this from running
await session.endSession();
}
await device.save();
}
}

View file

@ -23,7 +23,7 @@ async function PNIDMiddleware(request, response, next) {
if (!user) {
response.status(401);
if (type === 'Bearer') {
return response.send(xmlbuilder.create({
errors: {
@ -35,7 +35,7 @@ async function PNIDMiddleware(request, response, next) {
}
}).end());
}
return response.send(xmlbuilder.create({
errors: {
error: {

View file

@ -1,10 +1,11 @@
const xmlbuilder = require('xmlbuilder');
const { document: xmlParser } = require('xmlbuilder2');
function XMLMiddleware(request, response, next) {
if (request.method == 'POST' || request.method == 'PUT') {
const headers = request.headers;
let body = '';
if (
!headers['content-type'] ||
!headers['content-type'].toLowerCase().includes('xml')
@ -12,6 +13,13 @@ function XMLMiddleware(request, response, next) {
return next();
}
if (
!headers['content-length'] ||
parseInt(headers['content-length']) === 0
) {
return next();
}
request.setEncoding('utf-8');
request.on('data', (chunk) => {
body += chunk;
@ -22,7 +30,17 @@ function XMLMiddleware(request, response, next) {
request.body = xmlParser(body);
request.body = request.body.toObject();
} catch (error) {
return next();
response.status(401);
// TODO: This is not a real error code, check to see if better one exists
return response.send(xmlbuilder.create({
errors: {
error: {
code: '0004',
message: 'XML parse error'
}
}
}).end());
}
next();

View file

@ -2,6 +2,14 @@ const { Schema, model } = require('mongoose');
const uniqueValidator = require('mongoose-unique-validator');
const NEXAccountSchema = new Schema({
device_type: {
type: String,
enum: [
// Only track the family here not the model
'wiiu',
'3ds',
]
},
pid: {
type: Number,
unique: true
@ -34,9 +42,7 @@ NEXAccountSchema.methods.generatePID = async function () {
let pid = Math.floor(Math.random() * (max - min + 1) + min);
const inuse = await NEXAccount.findOne({
pid
});
const inuse = await NEXAccount.findOne({ pid });
pid = (inuse ? await NEXAccount.generatePID() : pid);
@ -60,16 +66,6 @@ NEXAccountSchema.methods.generatePassword = function () {
this.set('password', output.join(''));
};
NEXAccountSchema.pre('save', async function (next) {
await this.generatePID();
if (this.get('password') === '') {
await this.generatePassword();
}
next();
});
const NEXAccount = model('NEXAccount', NEXAccountSchema);
module.exports = {

View file

@ -7,7 +7,7 @@ const TGA = require('tga');
const got = require('got');
const util = require('../util');
const { DeviceSchema } = require('./device');
const Mii = require('../mii');
const Mii = require('mii-js');
const PNIDSchema = new Schema({
access_level: {
@ -131,13 +131,9 @@ PNIDSchema.methods.generatePID = async function() {
};
PNIDSchema.methods.generateEmailValidationCode = async function() {
let code = Math.random().toFixed(6).split('.')[1]; // Dirty one-liner to generate numbers of 6 length and padded 0
const inuse = await PNID.findOne({
'identification.email_code': code
});
code = (inuse ? await PNID.generateEmailValidationCode() : code);
// WiiU passes the PID along with the email code
// Does not actually need to be unique to all users
const code = Math.random().toFixed(6).split('.')[1]; // Dirty one-liner to generate numbers of 6 length and padded 0
this.set('identification.email_code', code);
};
@ -193,10 +189,12 @@ PNIDSchema.methods.updateMii = async function({name, primary, data}) {
PNIDSchema.methods.generateMiiImages = async function() {
const miiData = this.get('mii.data');
const studioMii = new Mii(Buffer.from(miiData, 'base64'));
const converted = studioMii.toStudioMii();
const encodedStudioMiiData = converted.toString('hex');
const miiStudioUrl = `https://studio.mii.nintendo.com/miis/image.png?data=${encodedStudioMiiData}&type=face&width=128&instanceCount=1`;
const mii = new Mii(Buffer.from(miiData, 'base64'));
const miiStudioUrl = mii.studioUrl({
type: 'face',
width: '128',
instanceCount: '1',
});
const miiStudioNormalFaceImageData = await got(miiStudioUrl).buffer();
const pngData = await imagePixels(miiStudioNormalFaceImageData);
const tga = TGA.createTgaBuffer(pngData.width, pngData.height, pngData.data);
@ -208,12 +206,21 @@ PNIDSchema.methods.generateMiiImages = async function() {
const expressions = ['frustrated', 'smile_open_mouth', 'wink_left', 'sorrow', 'surprise_open_mouth'];
for (const expression of expressions) {
const miiStudioExpressionUrl = `https://studio.mii.nintendo.com/miis/image.png?data=${encodedStudioMiiData}&type=face&expression=${expression}&width=128&instanceCount=1`;
const miiStudioExpressionUrl = mii.studioUrl({
type: 'face',
expression: expression,
width: '128',
instanceCount: '1',
});
const miiStudioExpressionImageData = await got(miiStudioExpressionUrl).buffer();
await util.uploadCDNAsset('pn-cdn', `${userMiiKey}/${expression}.png`, miiStudioExpressionImageData, 'public-read');
}
const miiStudioBodyUrl = `https://studio.mii.nintendo.com/miis/image.png?data=${encodedStudioMiiData}&type=all_body&width=270&instanceCount=1`;
const miiStudioBodyUrl = mii.studioUrl({
type: 'all_body',
width: '270',
instanceCount: '1',
});
const miiStudioBodyImageData = await got(miiStudioBodyUrl).buffer();
await util.uploadCDNAsset('pn-cdn', `${userMiiKey}/body.png`, miiStudioBodyImageData, 'public-read');
};
@ -224,24 +231,6 @@ PNIDSchema.methods.getServerMode = function () {
return serverMode;
};
PNIDSchema.pre('save', async function(next) {
if (!this.isModified('password')) {
return next();
}
this.set('usernameLower', this.get('username').toLowerCase());
//await this.generatePID();
await this.generateEmailValidationCode();
await this.generateEmailValidationToken();
await this.generateMiiImages();
const primaryHash = util.nintendoPasswordHash(this.get('password'), this.get('pid'));
const hash = bcrypt.hashSync(primaryHash, 10);
this.set('password', hash);
next();
});
const PNID = model('PNID', PNIDSchema);
module.exports = {

View file

@ -1,12 +1,18 @@
process.title = 'Pretendo - Account';
const configManager = require('./config-manager');
configManager.configure();
const express = require('express');
const morgan = require('morgan');
const xmlparser = require('./middleware/xml-parser');
const cache = require('./cache');
const database = require('./database');
const util = require('./util');
const logger = require('../logger');
const config = require('../config.json');
const { config } = configManager;
const { http: { port } } = config;
const app = express();
@ -16,10 +22,10 @@ const nnid = require('./services/nnid');
const nasc = require('./services/nasc');
const datastore = require('./services/datastore');
const api = require('./services/api');
const localcdn = require('./services/local-cdn');
const assets = require('./services/assets');
// START APPLICATION
app.set('etag', false);
app.disable('x-powered-by');
// Create router
logger.info('Setting up Middleware');
@ -36,6 +42,8 @@ app.use(nnid);
app.use(nasc);
app.use(datastore);
app.use(api);
app.use(localcdn);
app.use(assets);
// 404 handler
logger.info('Creating 404 status handler');
@ -70,11 +78,16 @@ app.use((error, request, response) => {
});
});
// Starts the server
logger.info('Starting server');
async function main() {
// Starts the server
logger.info('Starting server');
await database.connect();
await cache.connect();
database.connect().then(() => {
app.listen(port, () => {
logger.success(`Server started on port ${port}`);
});
});
}
main().catch(console.error);

View file

@ -17,10 +17,14 @@ api.options('*', cors());
// Setup routes
logger.info('[USER API] Applying imported routes');
api.use('/v1/register', routes.V1.REGISTER);
api.use('/v1/login', routes.V1.LOGIN);
api.use('/v1/user', routes.V1.USER);
api.use('/v1/connections', routes.V1.CONNECTIONS);
api.use('/v1/email', routes.V1.EMAIL);
api.use('/v1/forgot-password', routes.V1.FORGOT_PASSWORD);
api.use('/v1/login', routes.V1.LOGIN);
api.use('/v1/register', routes.V1.REGISTER);
api.use('/v1/reset-password', routes.V1.RESET_PASSWORD);
api.use('/v1/user', routes.V1.USER);
// Main router for endpoints
const router = express.Router();

View file

@ -1,8 +1,11 @@
module.exports = {
V1: {
REGISTER: require('./v1/register'),
LOGIN: require('./v1/login'),
USER: require('./v1/user'),
CONNECTIONS: require('./v1/connections'),
EMAIL: require('./v1/email'),
FORGOT_PASSWORD: require('./v1/forgotPassword'),
LOGIN: require('./v1/login'),
REGISTER: require('./v1/register'),
RESET_PASSWORD: require('./v1/resetPassword'),
USER: require('./v1/user'),
}
};

View file

@ -0,0 +1,42 @@
const router = require('express').Router();
const moment = require('moment');
const { PNID } = require('../../../../models/pnid');
const util = require('../../../../util');
router.get('/verify', async (request, response) => {
const { token } = request.query;
if (!token || token.trim() == '') {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Missing email token'
});
}
const pnid = await PNID.findOne({
'identification.email_token': token
});
if (!pnid) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid email token'
});
}
const validatedDate = moment().format('YYYY-MM-DDTHH:MM:SS');
pnid.set('email.reachable', true);
pnid.set('email.validated', true);
pnid.set('email.validated_date', validatedDate);
await pnid.save();
await util.sendEmailConfirmedEmail(pnid);
response.status(200).send('Email validated. You may close this window');
});
module.exports = router;

View file

@ -0,0 +1,36 @@
const router = require('express').Router();
const validator = require('validator');
const database = require('../../../../database');
const util = require('../../../../util');
router.post('/', async (request, response) => {
const { body } = request;
const { input } = body;
if (!input || input.trim() === '') {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing input'
});
}
let pnid;
if (validator.isEmail(input)) {
pnid = await database.getUserByEmailAddress(input);
} else {
pnid = await database.getUserByUsername(input);
}
if (pnid) {
await util.sendForgotPasswordEmail(pnid);
}
response.json({
app: 'api',
status: 200
});
});
module.exports = router;

View file

@ -2,6 +2,7 @@ const router = require('express').Router();
const bcrypt = require('bcrypt');
const fs = require('fs-extra');
const database = require('../../../../database');
const cache = require('../../../../cache');
const util = require('../../../../util');
/**
@ -67,10 +68,7 @@ router.post('/', async (request, response) => {
});
}
} else {
const decryptedToken = util.decryptToken(Buffer.from(refresh_token, 'base64'));
const unpackedToken = util.unpackToken(decryptedToken);
pnid = await database.getUserByPID(unpackedToken.pid);
pnid = await database.getUserBearer(refresh_token);
if (!pnid) {
return response.status(400).json({
app: 'api',
@ -80,9 +78,9 @@ router.post('/', async (request, response) => {
}
}
const cryptoPath = `${__dirname}/../../../../../certs/access`;
const cryptoPath = `${__dirname}/../../../../../certs/service/account`;
if (!fs.pathExistsSync(cryptoPath)) {
if (!await fs.pathExists(cryptoPath)) {
// Need to generate keys
return response.status(500).json({
app: 'api',
@ -91,12 +89,12 @@ router.post('/', async (request, response) => {
});
}
const publicKey = fs.readFileSync(`${cryptoPath}/public.pem`);
const hmacSecret = fs.readFileSync(`${cryptoPath}/secret.key`);
const publicKey = await cache.getServicePublicKey('account');
const secretKey = await cache.getServiceSecretKey('account');
const cryptoOptions = {
public_key: publicKey,
hmac_secret: hmacSecret
hmac_secret: secretKey
};
const accessTokenOptions = {
@ -117,8 +115,8 @@ router.post('/', async (request, response) => {
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const accessToken = util.generateToken(cryptoOptions, accessTokenOptions);
const refreshToken = util.generateToken(cryptoOptions, refreshTokenOptions);
const accessToken = await util.generateToken(cryptoOptions, accessTokenOptions);
const refreshToken = await util.generateToken(cryptoOptions, refreshTokenOptions);
response.json({
access_token: accessToken,

View file

@ -4,11 +4,15 @@ const fs = require('fs-extra');
const moment = require('moment');
const crypto = require('crypto');
const hcaptcha = require('hcaptcha');
const bcrypt = require('bcrypt');
const Mii = require('mii-js');
const { PNID } = require('../../../../models/pnid');
const { NEXAccount } = require('../../../../models/nex-account');
const database = require('../../../../database');
const cache = require('../../../../cache');
const util = require('../../../../util');
const config = require('../../../../../config');
const logger = require('../../../../../logger');
const { config, disabledFeatures } = require('../../../../config-manager');
const PNID_VALID_CHARACTERS_REGEX = /^[\w\-\.]*$/gm;
const PNID_PUNCTUATION_START_REGEX = /^[\_\-\.]/gm;
@ -21,6 +25,8 @@ const PASSWORD_WORD_OR_PUNCTUATION_REGEX = /(?=.*[a-zA-Z])(?=.*[\_\-\.]).*/;
const PASSWORD_NUMBER_OR_PUNCTUATION_REGEX = /(?=.*\d)(?=.*[\_\-\.]).*/;
const PASSWORD_REPEATED_CHARACTER_REGEX = /(.)\1\1/;
const DEFAULT_MII_DATA = Buffer.from('AwAAQOlVognnx0GC2/uogAOzuI0n2QAAAEBEAGUAZgBhAHUAbAB0AAAAAAAAAEBAAAAhAQJoRBgmNEYUgRIXaA0AACkAUkhQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGm9', 'base64');
/**
* [POST]
* Implementation of: https://api.pretendo.cc/v1/register
@ -28,7 +34,7 @@ const PASSWORD_REPEATED_CHARACTER_REGEX = /(.)\1\1/;
*/
router.post('/', async (request, response) => {
const { body } = request;
const email = body.email?.trim();
const username = body.username?.trim();
const miiName = body.mii_name?.trim();
@ -36,22 +42,24 @@ router.post('/', async (request, response) => {
const passwordConfirm = body.password_confirm?.trim();
const hCaptchaResponse = body.hCaptchaResponse?.trim();
if (!hCaptchaResponse || hCaptchaResponse === '') {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Must fill in captcha'
});
}
if (!disabledFeatures.captcha) {
if (!hCaptchaResponse || hCaptchaResponse === '') {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Must fill in captcha'
});
}
const captchaVerify = await hcaptcha.verify(config.hcaptcha.secret, hCaptchaResponse);
const captchaVerify = await hcaptcha.verify(config.hcaptcha.secret, hCaptchaResponse);
if (!captchaVerify.success) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Captcha verification failed'
});
if (!captchaVerify.success) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Captcha verification failed'
});
}
}
if (!email || email === '') {
@ -95,6 +103,7 @@ router.post('/', async (request, response) => {
}
if (!PNID_VALID_CHARACTERS_REGEX.test(username)) {
console.log(Buffer.from(username));
return response.status(400).json({
app: 'api',
status: 400,
@ -210,99 +219,110 @@ router.post('/', async (request, response) => {
});
}
// Default Mii data before Mii name
const MII_DATA_FIRST = Buffer.from([
0x03, 0x00, 0x00, 0x40, 0xE9, 0x55, 0xA2, 0x09,
0xE7, 0xC7, 0x41, 0x82, 0xDA, 0xA8, 0xE1, 0x77,
0x03, 0xB3, 0xB8, 0x8D, 0x27, 0xD9, 0x00, 0x00,
0x00, 0x60
]);
const MII_DATA_NAME = Buffer.alloc(0x14); // Max Mii name length
// Default Mii data after Mii name
const MII_DATA_LAST = Buffer.from([
0x40, 0x40, 0x00, 0x00, 0x21, 0x01, 0x02, 0x68,
0x44, 0x18, 0x26, 0x34, 0x46, 0x14, 0x81, 0x12,
0x17, 0x68, 0x0D, 0x00, 0x00, 0x29, 0x00, 0x52,
0x48, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x7C, 0x2E
]);
miiNameBuffer.copy(MII_DATA_NAME); // Move Mii name into padded buffer
const MII_DATA = Buffer.concat([MII_DATA_FIRST, MII_DATA_NAME, MII_DATA_LAST]); // Build Mii data
// Create new NEX account
const newNEXAccount = new NEXAccount({
pid: 0,
password: '',
owning_pid: 0,
});
await newNEXAccount.save();
const mii = new Mii(DEFAULT_MII_DATA);
mii.miiName = miiName;
const creationDate = moment().format('YYYY-MM-DDTHH:MM:SS');
let pnid;
let nexAccount;
const document = {
pid: newNEXAccount.get('pid'),
creation_date: creationDate,
updated: creationDate,
username: username,
password: password, // will be hashed before saving
birthdate: '1990-01-01', // TODO: Change this
gender: 'M', // TODO: Change this
country: 'US', // TODO: Change this
language: 'en', // TODO: Change this
email: {
address: email,
primary: true, // TODO: Change this
parent: true, // TODO: Change this
reachable: false, // TODO: Change this
validated: false, // TODO: Change this
id: crypto.randomBytes(4).readUInt32LE()
},
region: 0x310B0000, // TODO: Change this
timezone: {
name: 'America/New_York', // TODO: Change this
offset: -14400 // TODO: Change this
},
mii: {
name: miiName,
primary: true, // TODO: Change this
data: MII_DATA.toString('base64'),
id: crypto.randomBytes(4).readUInt32LE(),
hash: crypto.randomBytes(7).toString('hex'),
image_url: '', // deprecated, will be removed in the future
image_id: crypto.randomBytes(4).readUInt32LE()
},
flags: {
active: true, // TODO: Change this
marketing: true, // TODO: Change this
off_device: true // TODO: Change this
},
validation: {
email_code: 1, // will be overwritten before saving
email_token: '' // will be overwritten before saving
}
};
const session = await database.connection.startSession();
await session.startTransaction();
const pnid = new PNID(document);
await pnid.save();
try {
// * PNIDs can only be registered from a Wii U
// * So assume website users are WiiU NEX accounts
nexAccount = new NEXAccount({
device_type: 'wiiu',
});
// Quick hack to get the PIDs to match
// TODO: Change this
// NN with a NNID will always use the NNID PID
// even if the provided NEX PID is different
// To fix this we make them the same PID
await NEXAccount.updateOne({ pid: newNEXAccount.get('pid') }, {
owning_pid: newNEXAccount.get('pid')
});
await nexAccount.generatePID();
await nexAccount.generatePassword();
const cryptoPath = `${__dirname}/../../../../../certs/access`;
// Quick hack to get the PIDs to match
// TODO: Change this maybe?
// NN with a NNID will always use the NNID PID
// even if the provided NEX PID is different
// To fix this we make them the same PID
nexAccount.owning_pid = nexAccount.get('pid');
if (!fs.pathExistsSync(cryptoPath)) {
await nexAccount.save({ session });
const primaryPasswordHash = util.nintendoPasswordHash(password, nexAccount.get('pid'));
const passwordHash = await bcrypt.hash(primaryPasswordHash, 10);
pnid = new PNID({
pid: nexAccount.get('pid'),
creation_date: creationDate,
updated: creationDate,
username: username,
usernameLower: username.toLowerCase(),
password: passwordHash,
birthdate: '1990-01-01', // TODO: Change this
gender: 'M', // TODO: Change this
country: 'US', // TODO: Change this
language: 'en', // TODO: Change this
email: {
address: email.toLowerCase(),
primary: true, // TODO: Change this
parent: true, // TODO: Change this
reachable: false, // TODO: Change this
validated: false, // TODO: Change this
id: crypto.randomBytes(4).readUInt32LE()
},
region: 0x310B0000, // TODO: Change this
timezone: {
name: 'America/New_York', // TODO: Change this
offset: -14400 // TODO: Change this
},
mii: {
name: miiName,
primary: true, // TODO: Change this
data: mii.encode().toString('base64'),
id: crypto.randomBytes(4).readUInt32LE(),
hash: crypto.randomBytes(7).toString('hex'),
image_url: '', // deprecated, will be removed in the future
image_id: crypto.randomBytes(4).readUInt32LE()
},
flags: {
active: true, // TODO: Change this
marketing: true, // TODO: Change this
off_device: true // TODO: Change this
},
identification: {
email_code: 1, // will be overwritten before saving
email_token: '' // will be overwritten before saving
}
});
await pnid.generateEmailValidationCode();
await pnid.generateEmailValidationToken();
await pnid.generateMiiImages();
await pnid.save({ session });
await session.commitTransaction();
} catch (error) {
logger.error('[POST] /v1/register: ' + error);
await session.abortTransaction();
return response.status(400).json({
app: 'api',
status: 400,
error: 'Password must have combination of letters, numbers, and/or punctuation characters'
});
} finally {
// * This runs regardless of failure
// * Returning on catch will not prevent this from running
await session.endSession();
}
await util.sendConfirmationEmail(pnid);
const cryptoPath = `${__dirname}/../../../../../certs/service/account`;
if (!await fs.pathExists(cryptoPath)) {
// Need to generate keys
return response.status(500).json({
app: 'api',
@ -311,12 +331,12 @@ router.post('/', async (request, response) => {
});
}
const publicKey = fs.readFileSync(`${cryptoPath}/public.pem`);
const hmacSecret = fs.readFileSync(`${cryptoPath}/secret.key`);
const publicKey = await cache.getServicePublicKey('account');
const secretKey = await cache.getServiceSecretKey('account');
const cryptoOptions = {
public_key: publicKey,
hmac_secret: hmacSecret
hmac_secret: secretKey
};
const accessTokenOptions = {
@ -337,8 +357,8 @@ router.post('/', async (request, response) => {
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const accessToken = util.generateToken(cryptoOptions, accessTokenOptions);
const refreshToken = util.generateToken(cryptoOptions, refreshTokenOptions);
const accessToken = await util.generateToken(cryptoOptions, accessTokenOptions);
const refreshToken = await util.generateToken(cryptoOptions, refreshTokenOptions);
response.json({
access_token: accessToken,

View file

@ -0,0 +1,127 @@
const router = require('express').Router();
const bcrypt = require('bcrypt');
const { PNID } = require('../../../../models/pnid');
const util = require('../../../../util');
// This sucks
const PASSWORD_WORD_OR_NUMBER_REGEX = /(?=.*[a-zA-Z])(?=.*\d).*/;
const PASSWORD_WORD_OR_PUNCTUATION_REGEX = /(?=.*[a-zA-Z])(?=.*[\_\-\.]).*/;
const PASSWORD_NUMBER_OR_PUNCTUATION_REGEX = /(?=.*\d)(?=.*[\_\-\.]).*/;
const PASSWORD_REPEATED_CHARACTER_REGEX = /(.)\1\1/;
router.post('/', async (request, response) => {
const { body } = request;
const password = body.password?.trim();
const passwordConfirm = body.password_confirm?.trim();
const token = body.token?.trim();
if (!token || token === '') {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Missing token'
});
}
let unpackedToken;
try {
const decryptedToken = await util.decryptToken(Buffer.from(token, 'base64'));
unpackedToken = util.unpackToken(decryptedToken);
} catch (error) {
console.log(error);
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid token'
});
}
if (unpackedToken.expire_time < Date.now()) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Token expired'
});
}
const pnid = await PNID.findOne({ pid: unpackedToken.pid });
if (!pnid) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid token. No user found'
});
}
if (!password || password === '') {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Must enter a password'
});
}
if (password.length < 6) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Password is too short'
});
}
if (password.length > 16) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Password is too long'
});
}
if (password.toLowerCase() === pnid.get('usernameLower')) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Password cannot be the same as username'
});
}
if (!PASSWORD_WORD_OR_NUMBER_REGEX.test(password) && !PASSWORD_WORD_OR_PUNCTUATION_REGEX.test(password) && !PASSWORD_NUMBER_OR_PUNCTUATION_REGEX.test(password)) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Password must have combination of letters, numbers, and/or punctuation characters'
});
}
if (PASSWORD_REPEATED_CHARACTER_REGEX.test(password)) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Password may not have 3 repeating characters'
});
}
if (password !== passwordConfirm) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Passwords do not match'
});
}
const primaryPasswordHash = util.nintendoPasswordHash(password, pnid.get('pid'));
const passwordHash = await bcrypt.hash(primaryPasswordHash, 10);
pnid.password = passwordHash;
await pnid.save();
response.json({
app: 'api',
status: 200
});
});
module.exports = router;

View file

@ -1,5 +1,16 @@
const router = require('express').Router();
const joi = require('joi');
const { PNID } = require('../../../../models/pnid');
const { config } = require('../../../../config-manager');
// TODO: Extend this later with more settings
const userSchema = joi.object({
mii: joi.object({
name: joi.string(),
primary: joi.string(),
data: joi.string(),
})
});
/**
* [GET]
@ -36,7 +47,7 @@ router.get('/', async (request, response) => {
mii: {
data: pnid.get('mii.data'),
name: pnid.get('mii.name'),
image_url: `https://pretendo-cdn.b-cdn.net/mii/${pnid.get('pid')}/normal_face.png`
image_url: `${config.cdn.base_url}/mii/${pnid.get('pid')}/normal_face.png`
},
flags: {
marketing: pnid.get('flags.marketing')
@ -65,12 +76,14 @@ router.post('/', async (request, response) => {
});
}
if (body.mii && typeof body.mii === 'object' && body.mii.name && body.mii.primary && body.mii.data) {
const name = body.mii.name;
const primary = body.mii.primary;
const data = body.mii.data;
const valid = userSchema.validate(body);
await pnid.updateMii({ name, primary, data });
if (valid.error) {
return response.status(400).json({
app: 'api',
status: 400,
error: valid.error
});
}
const { pid } = pnid;
@ -98,7 +111,7 @@ router.post('/', async (request, response) => {
mii: {
data: pnid.get('mii.data'),
name: pnid.get('mii.name'),
image_url: `https://pretendo-cdn.b-cdn.net/mii/${pnid.get('pid')}/normal_face.png`
image_url: `${config.cdn.base_url}/mii/${pnid.get('pid')}/normal_face.png`
},
flags: {
marketing: pnid.get('flags.marketing')

View file

@ -0,0 +1,22 @@
// handles serving assets
const express = require('express');
const subdomain = require('express-subdomain');
const logger = require('../../../logger');
const path = require('path');
// Router to handle the subdomain restriction
const assets = express.Router();
// Setup public folder
logger.info('[assets] Setting up public folder');
assets.use(express.static(path.join(__dirname, '../../assets')));
// Main router for endpoints
const router = express.Router();
// Create subdomains
logger.info('[conntest] Creating \'assets\' subdomain');
router.use(subdomain('assets', assets));
module.exports = router;

View file

@ -0,0 +1,30 @@
const express = require('express');
const subdomain = require('express-subdomain');
const logger = require('../../../logger');
const { config, disabledFeatures } = require('../../config-manager');
if (!disabledFeatures.s3) {
// * s3 enabled, no need for this
module.exports = express.Router();
return;
}
const routes = require('./routes');
// Router to handle the subdomain
const localcdn = express.Router();
// Setup routes
logger.info('[LOCAL-CDN] Applying imported routes');
localcdn.use(routes.GET);
// Main router for endpoints
const router = express.Router();
// Create subdomains
logger.info(`[LOCAL-CDN] Creating '${config.cdn.subdomain}' subdomain`);
router.use(subdomain(config.cdn.subdomain, localcdn));
module.exports = router;

View file

@ -0,0 +1,16 @@
const router = require('express').Router();
const cache = require('../../../cache');
router.get('/*', async (request, response) => {
const filePath = request.params[0];
const file = await cache.getLocalCDNFile(filePath);
if (file) {
response.send(file);
} else {
response.sendStatus(404);
}
});
module.exports = router;

View file

@ -0,0 +1,3 @@
module.exports = {
GET: require('./get.js'),
};

View file

@ -1,7 +1,8 @@
const fs = require('fs');
const fs = require('fs-extra');
const express = require('express');
const util = require('../../../util');
const database = require('../../../database');
const cache = require('../../../cache');
const router = express.Router();
@ -27,7 +28,6 @@ router.post('/', async (request, response) => {
response.status(200).send(responseData.toString());
});
/**
*
* @param {express.Request} request
@ -55,12 +55,12 @@ async function processLoginRequest(request) {
const cryptoPath = `${__dirname}/../../../../certs/nex/${service_name}`;
const publicKey = fs.readFileSync(`${cryptoPath}/public.pem`);
const hmacSecret = fs.readFileSync(`${cryptoPath}/secret.key`);
const publicKey = await cache.getNEXPublicKey(service_name);
const secretKey = await cache.getNEXSecretKey(service_name);
const cryptoOptions = {
public_key: publicKey,
hmac_secret: hmacSecret
hmac_secret: secretKey
};
const tokenOptions = {
@ -72,7 +72,7 @@ async function processLoginRequest(request) {
expire_time: BigInt(Date.now() + (3600 * 1000))
};
let nexToken = util.generateToken(cryptoOptions, tokenOptions);
let nexToken = await util.generateToken(cryptoOptions, tokenOptions);
nexToken = util.nintendoBase64Encode(Buffer.from(nexToken, 'base64'));
const params = new URLSearchParams({

View file

@ -1,6 +1,7 @@
const router = require('express').Router();
const xmlbuilder = require('xmlbuilder');
const { PNID } = require('../../../models/pnid');
const { config } = require('../../../config-manager');
/**
* [GET]
@ -19,51 +20,51 @@ router.get('/', async (request, response) => {
const miiImages = [
{
cached_url: `https://pretendo-cdn.b-cdn.net/mii/${user.pid}/normal_face.png`,
cached_url: `${config.cdn.base_url}/mii/${user.pid}/normal_face.png`,
id: mii.id,
url: `https://pretendo-cdn.b-cdn.net/mii/${user.pid}/normal_face.png`,
url: `${config.cdn.base_url}/mii/${user.pid}/normal_face.png`,
type: 'standard'
},
{
cached_url: `https://pretendo-cdn.b-cdn.net/mii/${user.pid}/frustrated.png`,
cached_url: `${config.cdn.base_url}/mii/${user.pid}/frustrated.png`,
id: mii.id,
url: `https://pretendo-cdn.b-cdn.net/mii/${user.pid}/frustrated.png`,
url: `${config.cdn.base_url}/mii/${user.pid}/frustrated.png`,
type: 'frustrated_face'
},
{
cached_url: `https://pretendo-cdn.b-cdn.net/mii/${user.pid}/smile_open_mouth.png`,
cached_url: `${config.cdn.base_url}/mii/${user.pid}/smile_open_mouth.png`,
id: mii.id,
url: `https://pretendo-cdn.b-cdn.net/mii/${user.pid}/smile_open_mouth.png`,
url: `${config.cdn.base_url}/mii/${user.pid}/smile_open_mouth.png`,
type: 'happy_face'
},
{
cached_url: `https://pretendo-cdn.b-cdn.net/mii/${user.pid}/wink_left.png`,
cached_url: `${config.cdn.base_url}/mii/${user.pid}/wink_left.png`,
id: mii.id,
url: `https://pretendo-cdn.b-cdn.net/mii/${user.pid}/wink_left.png`,
url: `${config.cdn.base_url}/mii/${user.pid}/wink_left.png`,
type: 'like_face'
},
{
cached_url: `https://pretendo-cdn.b-cdn.net/mii/${user.pid}/normal_face.png`,
cached_url: `${config.cdn.base_url}/mii/${user.pid}/normal_face.png`,
id: mii.id,
url: `https://pretendo-cdn.b-cdn.net/mii/${user.pid}/normal_face.png`,
url: `${config.cdn.base_url}/mii/${user.pid}/normal_face.png`,
type: 'normal_face'
},
{
cached_url: `https://pretendo-cdn.b-cdn.net/mii/${user.pid}/sorrow.png`,
cached_url: `${config.cdn.base_url}/mii/${user.pid}/sorrow.png`,
id: mii.id,
url: `https://pretendo-cdn.b-cdn.net/mii/${user.pid}/sorrow.png`,
url: `${config.cdn.base_url}/mii/${user.pid}/sorrow.png`,
type: 'puzzled_face'
},
{
cached_url: `https://pretendo-cdn.b-cdn.net/mii/${user.pid}/surprised_open_mouth.png`,
cached_url: `${config.cdn.base_url}/mii/${user.pid}/surprised_open_mouth.png`,
id: mii.id,
url: `https://pretendo-cdn.b-cdn.net/mii/${user.pid}/surprised_open_mouth.png`,
url: `${config.cdn.base_url}/mii/${user.pid}/surprised_open_mouth.png`,
type: 'surprised_face'
},
{
cached_url: `https://pretendo-cdn.b-cdn.net/mii/${user.pid}/body.png`,
cached_url: `${config.cdn.base_url}/mii/${user.pid}/body.png`,
id: mii.id,
url: `https://pretendo-cdn.b-cdn.net/mii/${user.pid}/body.png`,
url: `${config.cdn.base_url}/mii/${user.pid}/body.png`,
type: 'whole_body'
}
];

View file

@ -35,7 +35,7 @@ router.post('/access_token/generate', async (request, response) => {
}
}).end());
}
if (!password || password.trim() === '') {
response.status(400);
return response.send(xmlbuilder.create({
@ -49,7 +49,7 @@ router.post('/access_token/generate', async (request, response) => {
const pnid = await database.getUserByUsername(user_id);
if (!pnid || !bcrypt.compareSync(password, pnid.password)) {
if (!pnid || !await bcrypt.compare(password, pnid.password)) {
response.status(400);
return response.send(xmlbuilder.create({
error: {
@ -70,15 +70,15 @@ router.post('/access_token/generate', async (request, response) => {
}).end());
}
const cryptoPath = `${__dirname}/../../../../certs/access`;
const cryptoPath = `${__dirname}/../../../../certs/service/account`;
if (!fs.pathExistsSync(cryptoPath)) {
if (!await fs.pathExists(cryptoPath)) {
// Need to generate keys
return response.send(xmlbuilder.create({
errors: {
error: {
code: '0000',
message: 'Could not find access key crypto path'
message: 'Could not find account access key crypto path'
}
}
}).end());
@ -98,8 +98,8 @@ router.post('/access_token/generate', async (request, response) => {
expire_time: BigInt(Date.now() + (3600 * 1000))
};
let accessToken = util.generateToken(null, accessTokenOptions);
let refreshToken = util.generateToken(null, refreshTokenOptions);
let accessToken = await util.generateToken(null, accessTokenOptions);
let refreshToken = await util.generateToken(null, refreshTokenOptions);
if (request.isCemu) {
accessToken = Buffer.from(accessToken, 'base64').toString('hex');

View file

@ -2,12 +2,14 @@ const router = require('express').Router();
const xmlbuilder = require('xmlbuilder');
const moment = require('moment');
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const { PNID } = require('../../../models/pnid');
const { NEXAccount } = require('../../../models/nex-account');
const deviceCertificateMiddleware = require('../../../middleware/device-certificate');
const ratelimit = require('../../../middleware/ratelimit');
const database = require('../../../database');
const mailer = require('../../../mailer');
const util = require('../../../util');
const logger = require('../../../../logger');
require('moment-timezone');
/**
@ -73,88 +75,109 @@ router.post('/', ratelimit, deviceCertificateMiddleware, async (request, respons
}).end());
}
// Create new NEX account
const newNEXAccount = new NEXAccount({
pid: 0,
password: '',
owning_pid: 0,
});
await newNEXAccount.save();
const creationDate = moment().format('YYYY-MM-DDTHH:MM:SS');
let pnid;
let nexAccount;
const document = {
pid: newNEXAccount.get('pid'),
creation_date: creationDate,
updated: creationDate,
username: person.get('user_id'),
password: person.get('password'), // will be hashed before saving
birthdate: person.get('birth_date'),
gender: person.get('gender'),
country: person.get('country'),
language: person.get('language'),
email: {
address: person.get('email').get('address'),
primary: person.get('email').get('primary') === 'Y',
parent: person.get('email').get('parent') === 'Y',
reachable: false,
validated: person.get('email').get('validated') === 'Y',
id: crypto.randomBytes(4).readUInt32LE()
},
region: person.get('region'),
timezone: {
name: person.get('tz_name'),
offset: (moment.tz(person.get('tz_name')).utcOffset() * 60)
},
mii: {
name: person.get('mii').get('name'),
primary: person.get('mii').get('name') === 'Y',
data: person.get('mii').get('data'),
id: crypto.randomBytes(4).readUInt32LE(),
hash: crypto.randomBytes(7).toString('hex'),
image_url: '', // deprecated, will be removed in the future
image_id: crypto.randomBytes(4).readUInt32LE()
},
flags: {
active: true,
marketing: person.get('marketing_flag') === 'Y',
off_device: person.get('off_device_flag') === 'Y'
},
validation: {
email_code: 1, // will be overwritten before saving
email_token: '' // will be overwritten before saving
}
};
const session = await database.connection.startSession();
await session.startTransaction();
const newPNID = new PNID(document);
await newPNID.save();
try {
nexAccount = new NEXAccount({
device_type: 'wiiu',
});
// Quick hack to get the PIDs to match
// TODO: Change this
// NN with a NNID will always use the NNID PID
// even if the provided NEX PID is different
// To fix this we make them the same PID
await NEXAccount.updateOne({ pid: newNEXAccount.get('pid') }, {
owning_pid: newNEXAccount.get('pid')
});
await nexAccount.generatePID();
await nexAccount.generatePassword();
await mailer.send(
newPNID.get('email'),
'[Pretendo Network] Please confirm your e-mail address',
`Hello,
Your Pretendo Network ID activation is almost complete. Please click the link below to confirm your e-mail address and complete the activation process.
https://account.pretendo.cc/account/email-confirmation?token=` + newPNID.get('identification.email_token') + `
If you are unable to connect to the above URL, please enter the following confirmation code on the device to which your Prentendo Network ID is linked.
&lt;&lt;Confirmation code: ` + newPNID.get('identification.email_code') + '&gt;&gt;'
);
// Quick hack to get the PIDs to match
// TODO: Change this maybe?
// NN with a NNID will always use the NNID PID
// even if the provided NEX PID is different
// To fix this we make them the same PID
nexAccount.owning_pid = nexAccount.get('pid');
await nexAccount.save({ session });
const primaryPasswordHash = util.nintendoPasswordHash(person.get('password'), nexAccount.get('pid'));
const passwordHash = await bcrypt.hash(primaryPasswordHash, 10);
pnid = new PNID({
pid: nexAccount.get('pid'),
creation_date: creationDate,
updated: creationDate,
username: person.get('user_id'),
usernameLower: person.get('user_id').toLowerCase(),
password: passwordHash,
birthdate: person.get('birth_date'),
gender: person.get('gender'),
country: person.get('country'),
language: person.get('language'),
email: {
address: person.get('email').get('address').toLowerCase(),
primary: person.get('email').get('primary') === 'Y',
parent: person.get('email').get('parent') === 'Y',
reachable: false,
validated: person.get('email').get('validated') === 'Y',
id: crypto.randomBytes(4).readUInt32LE()
},
region: person.get('region'),
timezone: {
name: person.get('tz_name'),
offset: (moment.tz(person.get('tz_name')).utcOffset() * 60)
},
mii: {
name: person.get('mii').get('name'),
primary: person.get('mii').get('name') === 'Y',
data: person.get('mii').get('data'),
id: crypto.randomBytes(4).readUInt32LE(),
hash: crypto.randomBytes(7).toString('hex'),
image_url: '', // deprecated, will be removed in the future
image_id: crypto.randomBytes(4).readUInt32LE()
},
flags: {
active: true,
marketing: person.get('marketing_flag') === 'Y',
off_device: person.get('off_device_flag') === 'Y'
},
identification: {
email_code: 1, // will be overwritten before saving
email_token: '' // will be overwritten before saving
}
});
await pnid.generateEmailValidationCode();
await pnid.generateEmailValidationToken();
await pnid.generateMiiImages();
await pnid.save({ session });
await session.commitTransaction();
} catch (error) {
logger.error('[POST] /v1/api/people: ' + error);
await session.abortTransaction();
response.status(400);
return response.send(xmlbuilder.create({
error: {
cause: 'Bad Request',
code: '1600',
message: 'Unable to process request'
}
}).end());
} finally {
// * This runs regardless of failure
// * Returning on catch will not prevent this from running
await session.endSession();
}
await util.sendConfirmationEmail(pnid);
response.send(xmlbuilder.create({
person: {
pid: newPNID.get('pid')
pid: pnid.get('pid')
}
}).end());
});
@ -168,8 +191,7 @@ router.get('/@me/profile', async (request, response) => {
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', new Date().getTime());
const { pnid } = request;
const person = await database.getUserProfileJSONByPID(pnid.get('pid'));
@ -211,7 +233,7 @@ router.get('/@me/devices', async (request, response) => {
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', new Date().getTime());
const { pnid, headers } = request;
response.send(xmlbuilder.create({
@ -373,7 +395,10 @@ router.put('/@me', async (request, response) => {
const offDeviceFlag = person.get('off_device_flag') ? person.get('off_device_flag') === 'Y' : pnid.get('flags.off_device');
if (person.get('password')) {
pnid.password = person.get('password'); // this gets hashed in the pre-save function, don't worry
const primaryPasswordHash = util.nintendoPasswordHash(person.get('password'), pnid.get('pid'));
const passwordHash = await bcrypt.hash(primaryPasswordHash, 10);
pnid.password = passwordHash;
}
pnid.gender = gender;
@ -452,30 +477,18 @@ router.put('/@me/emails/@primary', async (request, response) => {
}).end());
}
pnid.email.address = email.get('address');
pnid.email.reachable = false;
pnid.email.validated = false;
pnid.email.id = crypto.randomBytes(4).readUInt32LE();
pnid.set('email.address', email.get('address').toLowerCase());
pnid.set('email.reachable', false);
pnid.set('email.validated', false);
pnid.set('email.validated_date', '');
pnid.set('email.id', crypto.randomBytes(4).readUInt32LE());
// TODO: Change these, they are slow
await pnid.generateEmailValidationCode();
await pnid.generateEmailValidationToken();
await pnid.save();
await mailer.send(
email.get('address'),
'[Pretendo Network] Please confirm your e-mail address',
`Hello,
Your Pretendo Network ID activation is almost complete. Please click the link below to confirm your e-mail address and complete the activation process.
https://account.pretendo.cc/account/email-confirmation?token=` + pnid.get('identification.email_token') + `
If you are unable to connect to the above URL, please enter the following confirmation code on the device to which your Prentendo Network ID is linked.
&lt;&lt;Confirmation code: ` + pnid.get('identification.email_code') + '&gt;&gt;'
);
await util.sendConfirmationEmail(pnid);
response.send('');
});

View file

@ -4,6 +4,7 @@ const fs = require('fs-extra');
const { NEXAccount } = require('../../../models/nex-account');
const util = require('../../../util');
const database = require('../../../database');
const cache = require('../../../cache');
/**
* [GET]
@ -32,7 +33,7 @@ router.get('/service_token/@me', async (request, response) => {
const cryptoPath = `${__dirname}/../../../../certs/${service_type}/${service_name}`;
if (!fs.pathExistsSync(cryptoPath)) {
if (!await fs.pathExists(cryptoPath)) {
// Need to generate keys
return response.send(xmlbuilder.create({
errors: {
@ -44,12 +45,12 @@ router.get('/service_token/@me', async (request, response) => {
}).end());
}
const publicKey = fs.readFileSync(`${cryptoPath}/public.pem`);
const hmacSecret = fs.readFileSync(`${cryptoPath}/secret.key`);
const publicKey = await cache.getServicePublicKey(service_name);
const secretKey = await cache.getServiceSecretKey(service_name);
const cryptoOptions = {
public_key: publicKey,
hmac_secret: hmacSecret
hmac_secret: secretKey
};
const tokenOptions = {
@ -61,7 +62,7 @@ router.get('/service_token/@me', async (request, response) => {
expire_time: BigInt(Date.now() + (3600 * 1000))
};
let serviceToken = util.generateToken(cryptoOptions, tokenOptions);
let serviceToken = await util.generateToken(cryptoOptions, tokenOptions);
if (request.isCemu) {
serviceToken = Buffer.from(serviceToken, 'base64').toString('hex');
@ -112,8 +113,8 @@ router.get('/nex_token/@me', async (request, response) => {
const titleId = request.headers['x-nintendo-title-id'];
const cryptoPath = `${__dirname}/../../../../certs/${service_type}/${service_name}`;
if (!fs.pathExistsSync(cryptoPath)) {
if (!await fs.pathExists(cryptoPath)) {
// Need to generate keys
return response.send(xmlbuilder.create({
errors: {
@ -125,12 +126,12 @@ router.get('/nex_token/@me', async (request, response) => {
}).end());
}
const publicKey = fs.readFileSync(`${cryptoPath}/public.pem`);
const hmacSecret = fs.readFileSync(`${cryptoPath}/secret.key`);
const publicKey = await cache.getNEXPublicKey(service_name);
const secretKey= await cache.getNEXSecretKey(service_name);
const cryptoOptions = {
public_key: publicKey,
hmac_secret: hmacSecret
hmac_secret: secretKey
};
const tokenOptions = {
@ -151,7 +152,7 @@ router.get('/nex_token/@me', async (request, response) => {
return response.send('<errors><error><cause/><code>0008</code><message>Not Found</message></error></errors>');
}
let nexToken = util.generateToken(cryptoOptions, tokenOptions);
let nexToken = await util.generateToken(cryptoOptions, tokenOptions);
if (request.isCemu) {
nexToken = Buffer.from(nexToken, 'base64').toString('hex');

View file

@ -1,6 +1,9 @@
const router = require('express').Router();
const dns = require('dns');
const xmlbuilder = require('xmlbuilder');
const moment = require('moment');
const util = require('../../../util');
const database = require('../../../database');
/**
* [POST]
@ -41,4 +44,105 @@ router.post('/validate/email', async (request, response) => {
});
});
/**
* [PUT]
* Replacement for: https://account.nintendo.net/v1/api/support/email_confirmation/:pid/:code
* Description: Verifies a users email via 6 digit code
*/
router.put('/email_confirmation/:pid/:code', async (request, response) => {
const { pid, code } = request.params;
const pnid = await database.getUserByPID(pid);
if (!pnid) {
return response.status(400).send(xmlbuilder.create({
errors: {
error: {
code: '0130',
message: 'PID has not been registered yet'
}
}
}).end());
}
if (pnid.get('identification.email_code') !== code) {
return response.status(400).send(xmlbuilder.create({
errors: {
error: {
code: '0116',
message: 'Missing or invalid verification code'
}
}
}).end());
}
const validatedDate = moment().format('YYYY-MM-DDTHH:MM:SS');
pnid.set('email.reachable', true);
pnid.set('email.validated', true);
pnid.set('email.validated_date', validatedDate);
await pnid.save();
await util.sendEmailConfirmedEmail(pnid);
response.status(200).send('');
});
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/support/resend_confirmation
* Description: Resends a users confirmation email
*/
router.get('/resend_confirmation', async (request, response) => {
const pid = request.headers['x-nintendo-pid'];
const pnid = await database.getUserByPID(pid);
if (!pnid) {
// TODO - Unsure if this is the right error
return response.status(400).send(xmlbuilder.create({
errors: {
error: {
code: '0130',
message: 'PID has not been registered yet'
}
}
}).end());
}
await util.sendConfirmationEmail(pnid);
response.status(200).send('');
});
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/support/forgotten_password/PID
* Description: Sends the user a password reset email
* NOTE: On NN this was a temp password that expired after 24 hours. We do not do that
*/
router.get('/forgotten_password/:pid', async (request, response) => {
const { pid } = request.params;
const pnid = await database.getUserByPID(pid);
if (!pnid) {
// TODO - Better errors
return response.status(400).send(xmlbuilder.create({
errors: {
error: {
cause: 'device_id',
code: '0113',
message: 'Unauthorized device'
}
}
}).end());
}
await util.sendForgotPasswordEmail(pnid);
response.status(200).send('');
});
module.exports = router;

View file

@ -1,15 +1,21 @@
const crypto = require('crypto');
const path = require('path');
const NodeRSA = require('node-rsa');
const fs = require('fs-extra');
const aws = require('aws-sdk');
const config = require('../config.json');
const fs = require('fs-extra');
const mailer = require('./mailer');
const cache = require('./cache');
const { config, disabledFeatures } = require('./config-manager');
const spacesEndpoint = new aws.Endpoint('nyc3.digitaloceanspaces.com');
const s3 = new aws.S3({
endpoint: spacesEndpoint,
accessKeyId: config.aws.spaces.key,
secretAccessKey: config.aws.spaces.secret
});
let s3;
if (!disabledFeatures.s3) {
s3 = new aws.S3({
endpoint: new aws.Endpoint(config.s3.endpoint),
accessKeyId: config.s3.key,
secretAccessKey: config.s3.secret
});
}
function nintendoPasswordHash(password, pid) {
const pidBuffer = Buffer.alloc(4);
@ -35,13 +41,13 @@ function nintendoBase64Encode(decoded) {
return encoded.replaceAll('+', '.').replaceAll('/', '-').replaceAll('=', '*');
}
function generateToken(cryptoOptions, tokenOptions) {
async function generateToken(cryptoOptions, tokenOptions) {
// Access and refresh tokens use a different format since they must be much smaller
// They take no extra crypto options
if (!cryptoOptions) {
const cryptoPath = `${__dirname}/../certs/access`;
const aesKey = Buffer.from(fs.readFileSync(`${cryptoPath}/aes.key`, { encoding: 'utf8' }), 'hex');
const aesKey = await cache.getServiceAESKey('account', 'hex');
const dataBuffer = Buffer.alloc(1 + 1 + 4 + 8);
dataBuffer.writeUInt8(tokenOptions.system_type, 0x0);
@ -122,13 +128,11 @@ function generateToken(cryptoOptions, tokenOptions) {
return token.toString('base64'); // Encode to base64 for transport
}
function decryptToken(token) {
async function decryptToken(token) {
// Access and refresh tokens use a different format since they must be much smaller
// Assume a small length means access or refresh token
if (token.length <= 32) {
const cryptoPath = `${__dirname}/../certs/access`;
const aesKey = Buffer.from(fs.readFileSync(`${cryptoPath}/aes.key`, { encoding: 'utf8' }), 'hex');
const aesKey = await cache.getServiceAESKey('account', 'hex');
const iv = Buffer.alloc(16);
@ -140,14 +144,10 @@ function decryptToken(token) {
return decryptedBody;
}
const cryptoPath = `${__dirname}/../certs/access`;
const privateKeyBytes = await cache.getServicePrivateKey('account');
const secretKey = await cache.getServiceSecretKey('account');
const cryptoOptions = {
private_key: fs.readFileSync(`${cryptoPath}/private.pem`),
hmac_secret: fs.readFileSync(`${cryptoPath}/secret.key`)
};
const privateKey = new NodeRSA(cryptoOptions.private_key, 'pkcs1-private-pem', {
const privateKey = new NodeRSA(privateKeyBytes, 'pkcs1-private-pem', {
environment: 'browser',
encryptionScheme: {
'hash': 'sha256',
@ -174,7 +174,7 @@ function decryptToken(token) {
let decryptedBody = decipher.update(encryptedBody);
decryptedBody = Buffer.concat([decryptedBody, decipher.final()]);
const hmac = crypto.createHmac('sha1', cryptoOptions.hmac_secret).update(decryptedBody);
const hmac = crypto.createHmac('sha1', secretKey).update(decryptedBody);
const calculatedSignature = hmac.digest();
if (!signature.equals(calculatedSignature)) {
@ -208,20 +208,32 @@ function unpackToken(token) {
function fullUrl(request) {
const protocol = request.protocol;
const host = request.host;
const path = request.originalUrl;
const opath = request.originalUrl;
return `${protocol}://${host}${path}`;
return `${protocol}://${host}${opath}`;
}
async function uploadCDNAsset(bucket, key, data, acl) {
const awsPutParams = {
Body: data,
Key: key,
Bucket: bucket,
ACL: acl
};
if (disabledFeatures.s3) {
await writeLocalCDNFile(key, data);
} else {
const awsPutParams = {
Body: data,
Key: key,
Bucket: bucket,
ACL: acl
};
await s3.putObject(awsPutParams).promise();
await s3.putObject(awsPutParams).promise();
}
}
async function writeLocalCDNFile(key, data) {
const filePath = config.cdn.disk_path;
const folder = path.dirname(filePath);
await fs.ensureDir(folder);
await fs.writeFile(filePath, data);
}
function nascError(errorCode) {
@ -234,6 +246,63 @@ function nascError(errorCode) {
return params;
}
async function sendConfirmationEmail(pnid) {
await mailer.sendMail({
to: pnid.get('email.address'),
subject: '[Pretendo Network] Please confirm your email address',
username: pnid.get('username'),
confirmation: {
href: `https://api.pretendo.cc/v1/email/verify?token=${pnid.get('identification.email_token')}`,
code: pnid.get('identification.email_code')
},
text: `Hello ${pnid.get('username')}! \r\n\r\nYour Pretendo Network ID activation is almost complete. Please click the link to confirm your e-mail address and complete the activation process: \r\nhttps://api.pretendo.cc/v1/email/verify?token=${pnid.get('identification.email_token')} \r\n\r\nYou may also enter the following 6-digit code on your console: ${pnid.get('identification.email_code')}`
});
}
async function sendEmailConfirmedEmail(pnid) {
await mailer.sendMail({
to: pnid.get('email.address'),
subject: '[Pretendo Network] Email address confirmed',
username: pnid.get('username'),
paragraph: 'your email address has been confirmed. We hope you have fun on Pretendo Network!',
text: `Dear ${pnid.get('username')}, \r\n\r\nYour email address has been confirmed. We hope you have fun on Pretendo Network!`
});
}
async function sendForgotPasswordEmail(pnid) {
const publicKey = await cache.getServicePublicKey('account');
const secretKey = await cache.getServiceSecretKey('account');
const cryptoOptions = {
public_key: publicKey,
hmac_secret: secretKey
};
const tokenOptions = {
system_type: 0xF, // API
token_type: 0x5, // Password reset
pid: pnid.get('pid'),
access_level: pnid.get('access_level'),
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (24 * 60 * 60 * 1000)) // Only valid for 24 hours
};
const passwordResetToken = await generateToken(cryptoOptions, tokenOptions);
await mailer.sendMail({
to: pnid.get('email.address'),
subject: '[Pretendo Network] Forgot Password',
username: pnid.get('username'),
paragraph: 'a password reset has been requested from this account. If you did not request the password reset, please ignore this email. If you did request this password reset, please click the link below to reset your password.',
link: {
text: 'Reset password',
href: `${config.website_base}/account/reset-password?token=${encodeURIComponent(passwordResetToken)}`
},
text: `Dear ${pnid.get('username')}, a password reset has been requested from this account. \r\n\r\nIf you did not request the password reset, please ignore this email. \r\nIf you did request this password reset, please click the link to reset your password: ${config.website_base}/account/reset-password?token=${encodeURIComponent(passwordResetToken)}`
});
}
module.exports = {
nintendoPasswordHash,
nintendoBase64Decode,
@ -243,5 +312,8 @@ module.exports = {
unpackToken,
fullUrl,
uploadCDNAsset,
nascError
nascError,
sendConfirmationEmail,
sendEmailConfirmedEmail,
sendForgotPasswordEmail
};