mirror of
https://github.com/PretendoNetwork/account.git
synced 2026-03-21 17:44:49 -05:00
chore: revert auth-update changes and hotpatches related
Commits: -f349b9de42-b9966807c3-0bc3af0507-008a517947
This commit is contained in:
parent
056e773fe8
commit
1a3445db5b
|
|
@ -13,7 +13,6 @@ import { PNIDProfile } from '@/types/services/nnas/pnid-profile';
|
|||
import { ConnectionData } from '@/types/services/api/connection-data';
|
||||
import { ConnectionResponse } from '@/types/services/api/connection-response';
|
||||
import { DiscordConnectionData } from '@/types/services/api/discord-connection-data';
|
||||
import { SystemType, TokenType } from '@/types/common/token';
|
||||
|
||||
const connection_string = config.mongoose.connection_string;
|
||||
const options = config.mongoose.options;
|
||||
|
|
@ -105,108 +104,12 @@ export async function getPNIDByBasicAuth(token: string): Promise<HydratedPNIDDoc
|
|||
return pnid;
|
||||
}
|
||||
|
||||
export async function getPNIDByNNASAccessToken(token: string): Promise<HydratedPNIDDocument | null> {
|
||||
export async function getPNIDByTokenAuth(token: string): Promise<HydratedPNIDDocument | null> {
|
||||
verifyConnected();
|
||||
|
||||
try {
|
||||
const decryptedToken = decryptToken(Buffer.from(token, 'hex'));
|
||||
const unpackedToken = unpackToken(decryptedToken);
|
||||
|
||||
// * Return if the system type isn't Wii U (NNAS) and the token type isn't "OAuth Access"
|
||||
if (unpackedToken.system_type !== SystemType.WIIU || unpackedToken.token_type !== TokenType.OAUTH_ACCESS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pnid = await getPNIDByPID(unpackedToken.pid);
|
||||
|
||||
if (pnid) {
|
||||
const expireTime = Math.floor((Number(unpackedToken.expire_time) / 1000));
|
||||
|
||||
if (Math.floor(Date.now() / 1000) > expireTime) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return pnid;
|
||||
} catch (error: any) {
|
||||
// TODO - Handle error
|
||||
LOG_ERROR(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPNIDByNNASRefreshToken(token: string): Promise<HydratedPNIDDocument | null> {
|
||||
verifyConnected();
|
||||
|
||||
try {
|
||||
const decryptedToken = decryptToken(Buffer.from(token, 'hex'));
|
||||
const unpackedToken = unpackToken(decryptedToken);
|
||||
|
||||
// * Return if the system type isn't Wii U (NNAS) and the token type isn't "OAuth Refresh"
|
||||
if (unpackedToken.system_type !== SystemType.WIIU || unpackedToken.token_type !== TokenType.OAUTH_ACCESS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pnid = await getPNIDByPID(unpackedToken.pid);
|
||||
|
||||
if (pnid) {
|
||||
const expireTime = Math.floor((Number(unpackedToken.expire_time) / 1000));
|
||||
|
||||
if (Math.floor(Date.now() / 1000) > expireTime) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return pnid;
|
||||
} catch (error: any) {
|
||||
// TODO - Handle error
|
||||
LOG_ERROR(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPNIDByAPIAccessToken(token: string): Promise<HydratedPNIDDocument | null> {
|
||||
verifyConnected();
|
||||
|
||||
try {
|
||||
const decryptedToken = decryptToken(Buffer.from(token, 'hex'));
|
||||
const unpackedToken = unpackToken(decryptedToken);
|
||||
|
||||
// * Return if the system type isn't API (REST and gRPC) and the token type isn't "OAuth Access"
|
||||
if (unpackedToken.system_type !== SystemType.API || unpackedToken.token_type !== TokenType.OAUTH_ACCESS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pnid = await getPNIDByPID(unpackedToken.pid);
|
||||
|
||||
if (pnid) {
|
||||
const expireTime = Math.floor((Number(unpackedToken.expire_time) / 1000));
|
||||
|
||||
if (Math.floor(Date.now() / 1000) > expireTime) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return pnid;
|
||||
} catch (error: any) {
|
||||
// TODO - Handle error
|
||||
LOG_ERROR(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPNIDByAPIRefreshToken(token: string): Promise<HydratedPNIDDocument | null> {
|
||||
verifyConnected();
|
||||
|
||||
try {
|
||||
const decryptedToken = decryptToken(Buffer.from(token, 'hex'));
|
||||
const unpackedToken = unpackToken(decryptedToken);
|
||||
|
||||
// * Return if the system type isn't API (REST and gRPC) and the token type isn't "OAuth Refresh"
|
||||
if (unpackedToken.system_type !== SystemType.API || unpackedToken.token_type !== TokenType.OAUTH_REFRESH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pnid = await getPNIDByPID(unpackedToken.pid);
|
||||
|
||||
if (pnid) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import express from 'express';
|
||||
import { getValueFromHeaders } from '@/util';
|
||||
import { getPNIDByAPIAccessToken } from '@/database';
|
||||
import { getPNIDByTokenAuth } from '@/database';
|
||||
import { LOG_ERROR } from '@/logger';
|
||||
|
||||
async function APIMiddleware(request: express.Request, _response: express.Response, next: express.NextFunction): Promise<void> {
|
||||
const authHeader = getValueFromHeaders(request.headers, 'authorization');
|
||||
|
||||
|
|
@ -12,7 +11,7 @@ async function APIMiddleware(request: express.Request, _response: express.Respon
|
|||
|
||||
try {
|
||||
const token = authHeader.split(' ')[1];
|
||||
const pnid = await getPNIDByAPIAccessToken(token);
|
||||
const pnid = await getPNIDByTokenAuth(token);
|
||||
|
||||
request.pnid = pnid;
|
||||
} catch (error: any) {
|
||||
|
|
|
|||
42
src/middleware/client-header.ts
Normal file
42
src/middleware/client-header.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import express from 'express';
|
||||
import xmlbuilder from 'xmlbuilder';
|
||||
import { getValueFromHeaders } from '@/util';
|
||||
|
||||
const VALID_CLIENT_ID_SECRET_PAIRS: Record<string, string> = {
|
||||
// * 'Key' is the client ID, 'Value' is the client secret
|
||||
'a2efa818a34fa16b8afbc8a74eba3eda': 'c91cdb5658bd4954ade78533a339cf9a', // * Possibly WiiU exclusive?
|
||||
'daf6227853bcbdce3d75baee8332b': '3eff548eac636e2bf45bb7b375e7b6b0', // * Possibly 3DS exclusive?
|
||||
'ea25c66c26b403376b4c5ed94ab9cdea': 'd137be62cb6a2b831cad8c013b92fb55', // * Possibly 3DS exclusive?
|
||||
};
|
||||
|
||||
function nintendoClientHeaderCheck(request: express.Request, response: express.Response, next: express.NextFunction): void {
|
||||
response.set('Content-Type', 'text/xml');
|
||||
response.set('Server', 'Nintendo 3DS (http)');
|
||||
response.set('X-Nintendo-Date', new Date().getTime().toString());
|
||||
|
||||
const clientID = getValueFromHeaders(request.headers, 'x-nintendo-client-id');
|
||||
const clientSecret = getValueFromHeaders(request.headers, 'x-nintendo-client-secret');
|
||||
|
||||
if (
|
||||
!clientID ||
|
||||
!clientSecret ||
|
||||
!VALID_CLIENT_ID_SECRET_PAIRS[clientID] ||
|
||||
clientSecret !== VALID_CLIENT_ID_SECRET_PAIRS[clientID]
|
||||
) {
|
||||
response.send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
cause: 'client_id',
|
||||
code: '0004',
|
||||
message: 'API application invalid or incorrect application credentials'
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
export default nintendoClientHeaderCheck;
|
||||
158
src/middleware/console-status-verification.ts
Normal file
158
src/middleware/console-status-verification.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import crypto from 'node:crypto';
|
||||
import express from 'express';
|
||||
import xmlbuilder from 'xmlbuilder';
|
||||
import { Device } from '@/models/device';
|
||||
import { getValueFromHeaders } from '@/util';
|
||||
|
||||
async function consoleStatusVerificationMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): Promise<void> {
|
||||
if (!request.certificate || !request.certificate.valid) {
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
error: {
|
||||
code: '0110',
|
||||
message: 'Unlinked device'
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceIDHeader = getValueFromHeaders(request.headers, 'x-nintendo-device-id');
|
||||
|
||||
if (!deviceIDHeader) {
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
error: {
|
||||
code: '0002',
|
||||
message: 'deviceId format is invalid'
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceID = Number(deviceIDHeader);
|
||||
|
||||
if (isNaN(deviceID)) {
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
error: {
|
||||
code: '0002',
|
||||
message: 'deviceId format is invalid'
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const serialNumber = getValueFromHeaders(request.headers, 'x-nintendo-serial-number');
|
||||
|
||||
// TODO - Verify serial numbers somehow?
|
||||
// * This is difficult to do safely because serial numbers are
|
||||
// * inherently insecure.
|
||||
// * Information about their structure can be found here:
|
||||
// * https://www.3dbrew.org/wiki/Serials
|
||||
// * Given this, anyone can generate a valid serial number which
|
||||
// * passes these checks, even if the serial number isn't real.
|
||||
// * The 3DS also futher complicates things, as it never sends
|
||||
// * the complete serial number. The 3DS omits the check digit,
|
||||
// * meaning any attempt to verify the serial number of a 3DS
|
||||
// * family of console will ALWAYS fail. Nintendo likely just
|
||||
// * has a database of all known serials which they are able to
|
||||
// * compare against. We are not so lucky
|
||||
if (!serialNumber) {
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
error: {
|
||||
code: '0002',
|
||||
message: 'serialNumber format is invalid'
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let device = await Device.findOne({
|
||||
serial: serialNumber,
|
||||
});
|
||||
|
||||
const certificateHash = crypto.createHash('sha256').update(request.certificate._certificate).digest('base64');
|
||||
|
||||
if (!device && request.certificate.consoleType === '3ds') {
|
||||
// * A 3DS console document will ALWAYS be created by NASC before
|
||||
// * Hitting the NNAS server. NASC stores the serial number at
|
||||
// * the time the device document was created. Therefore we can
|
||||
// * know that serial tampering happened on the 3DS if this fails
|
||||
// * to find a device document.
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
error: {
|
||||
code: '0002',
|
||||
message: 'serialNumber format is invalid'
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
} else if (device && !device.certificate_hash && request.certificate.consoleType === '3ds') {
|
||||
device.certificate_hash = certificateHash;
|
||||
|
||||
await device.save();
|
||||
}
|
||||
|
||||
device = await Device.findOne({
|
||||
certificate_hash: certificateHash,
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
// * Device must be a fresh Wii U
|
||||
device = await Device.create({
|
||||
model: 'wup',
|
||||
device_id: deviceID,
|
||||
serial: serialNumber,
|
||||
linked_pids: [],
|
||||
certificate_hash: certificateHash
|
||||
});
|
||||
}
|
||||
|
||||
if (device.serial !== serialNumber) {
|
||||
// TODO - Change this to a different error
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
error: {
|
||||
cause: 'Bad Request',
|
||||
code: '1600',
|
||||
message: 'Unable to process request'
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const certificateDeviceID = parseInt(request.certificate.certificateName.slice(2).split('-')[0], 16);
|
||||
|
||||
if (deviceID !== certificateDeviceID) {
|
||||
// TODO - Change this to a different error
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
error: {
|
||||
cause: 'Bad Request',
|
||||
code: '1600',
|
||||
message: 'Unable to process request'
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.access_level < 0) {
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
code: '0012',
|
||||
message: 'Device has been banned by game server' // TODO - This is not the right error message
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
request.device = device;
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
export default consoleStatusVerificationMiddleware;
|
||||
17
src/middleware/device-certificate.ts
Normal file
17
src/middleware/device-certificate.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import express from 'express';
|
||||
import NintendoCertificate from '@/nintendo-certificate';
|
||||
import { getValueFromHeaders } from '@/util';
|
||||
|
||||
function deviceCertificateMiddleware(request: express.Request, _response: express.Response, next: express.NextFunction): void {
|
||||
const certificate = getValueFromHeaders(request.headers, 'x-nintendo-device-cert');
|
||||
|
||||
if (!certificate) {
|
||||
return next();
|
||||
}
|
||||
|
||||
request.certificate = new NintendoCertificate(certificate);
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
export default deviceCertificateMiddleware;
|
||||
|
|
@ -53,7 +53,6 @@ async function NASCMiddleware(request: express.Request, response: express.Respon
|
|||
return;
|
||||
}
|
||||
|
||||
// TODO - Replace this with https://github.com/PretendoNetwork/nintendo-file-formats maybe?
|
||||
const cert = new NintendoCertificate(fcdcert);
|
||||
|
||||
if (!cert.valid) {
|
||||
|
|
|
|||
|
|
@ -1,264 +0,0 @@
|
|||
import express from 'express';
|
||||
import xmlbuilder from 'xmlbuilder';
|
||||
|
||||
const VALID_CLIENT_ID_SECRET_PAIRS: Record<string, string> = {
|
||||
// * 'Key' is the client ID, 'Value' is the client secret
|
||||
'a2efa818a34fa16b8afbc8a74eba3eda': 'c91cdb5658bd4954ade78533a339cf9a', // * Wii U
|
||||
'ea25c66c26b403376b4c5ed94ab9cdea': 'd137be62cb6a2b831cad8c013b92fb55' // * 3DS
|
||||
};
|
||||
|
||||
const SYSTEM_VERSIONS = {
|
||||
'0': '0320', // * 3DS
|
||||
'1': '0270' // * Wii U
|
||||
};
|
||||
|
||||
const REGIONS = [
|
||||
'1', // * JPN
|
||||
'2', // * USA
|
||||
'4', // * EUR
|
||||
'8', // * AUS
|
||||
'16', // * CHN
|
||||
'32', // * KOR
|
||||
'64' // * TWN
|
||||
];
|
||||
|
||||
const DEVICE_ID = /^\d{10}$/; // TODO - Are these ALWAYS 10 digits?
|
||||
const SERIAL_REGEX = /^[A-Z]{2,3}\d{8,9}$/; // TODO - This is not robust, and may be wrong. See brew wikis (https://www.3dbrew.org/wiki/Serials, https://wiiubrew.org/wiki/Product_Information#Product_Serial_Numbers)
|
||||
|
||||
// * Checks only for the existence of common headers and does some sanity checks
|
||||
function nnasBasicHeaderCheckMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): void {
|
||||
response.set('Content-Type', 'text/xml');
|
||||
response.set('Server', 'Nintendo 3DS (http)');
|
||||
response.set('X-Nintendo-Date', new Date().getTime().toString());
|
||||
|
||||
const platformID = request.header('X-Nintendo-Platform-ID');
|
||||
const deviceType = request.header('X-Nintendo-Device-Type');
|
||||
const deviceID = request.header('X-Nintendo-Device-ID');
|
||||
const serialNumber = request.header('X-Nintendo-Serial-Number');
|
||||
const systemVersion = request.header('X-Nintendo-System-Version');
|
||||
const region = request.header('X-Nintendo-Region');
|
||||
const country = request.header('X-Nintendo-Country');
|
||||
const clientID = request.header('X-Nintendo-Client-ID');
|
||||
const clientSecret = request.header('X-Nintendo-Client-Secret');
|
||||
const friendsVersion = request.header('X-Nintendo-FPD-Version');
|
||||
const environment = request.header('X-Nintendo-Environment');
|
||||
const titleID = request.header('X-Nintendo-Title-ID');
|
||||
const uniqueID = request.header('X-Nintendo-Unique-ID');
|
||||
const applicationVersion = request.header('X-Nintendo-Application-Version');
|
||||
const model = request.header('X-Nintendo-Device-Model');
|
||||
const deviceCertificate = request.header('X-Nintendo-Device-Cert');
|
||||
|
||||
// * 0 = 3DS, 1 = Wii U
|
||||
if (platformID === undefined || (platformID !== '0' && platformID !== '1')) {
|
||||
response.send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
code: '0002',
|
||||
message: 'platformId format is invalid'
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// * 1 = debug, 2 = retail
|
||||
if (deviceType === undefined || (deviceType !== '1' && deviceType !== '2')) {
|
||||
// TODO - Unsure if this is the right error
|
||||
response.send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
code: '0002',
|
||||
message: 'Device type format is invalid'
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (deviceID === undefined || !DEVICE_ID.test(deviceID)) {
|
||||
response.send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
code: '0002',
|
||||
message: 'deviceId format is invalid'
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (serialNumber === undefined || !SERIAL_REGEX.test(serialNumber)) {
|
||||
response.send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
code: '0002',
|
||||
message: 'serialNumber format is invalid'
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO - Should the version check throw SYSTEM_UPDATE_REQUIRED?
|
||||
if (systemVersion === undefined || SYSTEM_VERSIONS[platformID] !== systemVersion) {
|
||||
response.send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
code: '0002',
|
||||
message: 'version format is invalid'
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (region === undefined || !REGIONS.includes(region)) {
|
||||
response.send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
code: '0002',
|
||||
message: 'X-Nintendo-Region format is invalid'
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (country === undefined) {
|
||||
response.send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
code: '0002',
|
||||
message: 'X-Nintendo-Country format is invalid'
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO - Check the platform too?
|
||||
if (
|
||||
clientID === undefined ||
|
||||
clientSecret === undefined ||
|
||||
!VALID_CLIENT_ID_SECRET_PAIRS[clientID] ||
|
||||
clientSecret !== VALID_CLIENT_ID_SECRET_PAIRS[clientID]
|
||||
) {
|
||||
response.send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
cause: 'client_id',
|
||||
code: '0004',
|
||||
message: 'API application invalid or incorrect application credentials'
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (friendsVersion === undefined || friendsVersion !== '0000') {
|
||||
// TODO - Unsure if this is the right error
|
||||
response.send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
code: '0002',
|
||||
message: 'Friends version is invalid'
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO - Check this against valid list
|
||||
if (environment === undefined) {
|
||||
response.send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
code: '1017',
|
||||
message: 'The requested game environment wasn\'t found for the given game server.'
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (titleID === undefined) {
|
||||
// TODO - Unsure if this is the right error
|
||||
response.send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
code: '0002',
|
||||
message: 'Title ID format is invalid'
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (uniqueID === undefined) {
|
||||
// TODO - Unsure if this is the right error
|
||||
response.send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
code: '0002',
|
||||
message: 'Unique ID format is invalid'
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (applicationVersion === undefined) {
|
||||
// TODO - Unsure if this is the right error
|
||||
response.send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
code: '0002',
|
||||
message: 'Application version format is invalid'
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (platformID === '0' && model === undefined) {
|
||||
// TODO - Unsure if this is the right error
|
||||
response.send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
code: '0002',
|
||||
message: 'Model format is invalid'
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (platformID === '0' && deviceCertificate === undefined) {
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
error: {
|
||||
code: '0110',
|
||||
message: 'Unlinked device'
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
export default nnasBasicHeaderCheckMiddleware;
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
import crypto from 'node:crypto';
|
||||
import express from 'express';
|
||||
import xmlbuilder from 'xmlbuilder';
|
||||
import NintendoCertificate from '@/nintendo-certificate';
|
||||
import { Device } from '@/models/device';
|
||||
|
||||
// * These endpoints are requested by the Wii U prior to sending it's device certificate.
|
||||
// * We cannot validate Wii U console details in these endpoints
|
||||
const INSECURE_WIIU_ENDPOINTS = [
|
||||
/^\/v1\/api\/devices\/@current\/status\/?$/,
|
||||
/^\/v1\/api\/content\/agreements\/Nintendo-Network-EULA\/[A-Z]{2}\/@latest\/?$/, // TODO - Should this be a bit more flexible, changing the type and version?
|
||||
/^\/v1\/api\/content\/time_zones\/[A-Z]{2}\/[a-z]{2}\/?$/,
|
||||
/^\/v1\/api\/people\/\w{6,16}\/?$/, // TODO - "\w" is NOT the correct filter here, there's additional rules. But this works for now. See https://en-americas-support.nintendo.com/app/answers/detail/a_id/2221
|
||||
/^\/v1\/api\/support\/validate\/email\/?$/,
|
||||
/^\/v1\/account-settings\/?/ // * Disable all of these routes, don't check the end of the string
|
||||
];
|
||||
|
||||
// * These endpoints are known to always have a certificate, on both consoles.
|
||||
// * Any other endpoint only has a certificate sent to it on the 3DS, on the Wii U
|
||||
// * we can only use the device ID and serial for lookups in those cases
|
||||
const REQUIRED_CERT_CHECK_ENDPOINTS = [
|
||||
/^\/v1\/api\/oauth20\/access_token\/generate\/?$/,
|
||||
/^\/v1\/api\/people\/?$/,
|
||||
/^\/v1\/api\/people\/@me\/agreements\/?$/, // TODO - We don't actually implement this endpoint yet
|
||||
/^\/v1\/api\/people\/@me\/devices\/?$/,
|
||||
/^\/v1\/api\/people\/@me\/devices\/owner\/?$/
|
||||
];
|
||||
|
||||
async function nnasCheckDeviceMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): Promise<void> {
|
||||
const platformID = request.header('X-Nintendo-Platform-ID')!;
|
||||
const deviceID = Number(request.header('X-Nintendo-Device-ID')!);
|
||||
const serialNumber = request.header('X-Nintendo-Serial-Number')!;
|
||||
const deviceCertificate = request.header('X-Nintendo-Device-Cert');
|
||||
const path = request.originalUrl.split('?')[0];
|
||||
|
||||
if (platformID === '1' && INSECURE_WIIU_ENDPOINTS.some(regex => regex.test(path))) {
|
||||
// * Some Wii U endpoints cannot be validated, since they are called prior to seeing a certificate
|
||||
return next();
|
||||
}
|
||||
|
||||
// * 3DS ALWAYS sends the device certificate
|
||||
if (platformID === '0' && deviceCertificate == undefined) {
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
error: {
|
||||
code: '0110',
|
||||
message: 'Unlinked device'
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldCheckCertificate = deviceCertificate !== undefined || platformID === '0' || REQUIRED_CERT_CHECK_ENDPOINTS.some(regex => regex.test(path));
|
||||
|
||||
if (shouldCheckCertificate) {
|
||||
if (deviceCertificate === undefined) {
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
error: {
|
||||
code: '0110',
|
||||
message: 'Unlinked device'
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const certificate = new NintendoCertificate(deviceCertificate);
|
||||
|
||||
if (!certificate.valid) {
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
error: {
|
||||
code: '0110',
|
||||
message: 'Unlinked device'
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const certificateDeviceID = parseInt(certificate.certificateName.slice(2).split('-')[0], 16);
|
||||
|
||||
if (deviceID !== certificateDeviceID) {
|
||||
// TODO - Change this to a different error
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
error: {
|
||||
cause: 'Bad Request',
|
||||
code: '1600',
|
||||
message: 'Unable to process request'
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let device = await Device.findOne({
|
||||
serial: serialNumber,
|
||||
});
|
||||
|
||||
if (!device && certificate.consoleType === '3ds') {
|
||||
// * A 3DS console document will ALWAYS be created by NASC before
|
||||
// * Hitting the NNAS server. NASC stores the serial number at
|
||||
// * the time the device document was created. Therefore we can
|
||||
// * know that serial tampering happened on the 3DS if this fails
|
||||
// * to find a device document.
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
error: {
|
||||
code: '0002',
|
||||
message: 'serialNumber format is invalid'
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// * Update 3DS consoles to sync with the data from NASC
|
||||
const certificateHash = crypto.createHash('sha256').update(Buffer.from(deviceCertificate, 'base64')).digest('base64');
|
||||
|
||||
if (device && !device.certificate_hash && certificate.consoleType === '3ds') {
|
||||
// * First time seeing the 3DS in NNAS, link the device certificate
|
||||
device.certificate_hash = certificateHash;
|
||||
|
||||
await device.save();
|
||||
}
|
||||
|
||||
if (device && !device.device_id && certificate.consoleType === '3ds') {
|
||||
// * First time seeing the 3DS in NNAS, link the device ID
|
||||
device.device_id = certificateDeviceID;
|
||||
|
||||
await device.save();
|
||||
}
|
||||
|
||||
// * Real device lookup/validation is always done with the certificate
|
||||
device = await Device.findOne({
|
||||
certificate_hash: certificateHash,
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
if (certificate.consoleType === '3ds') {
|
||||
// * If this happens, something has gone horribly wrong
|
||||
// TODO - Change this to a different error
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
error: {
|
||||
cause: 'Bad Request',
|
||||
code: '1600',
|
||||
message: 'Unable to process request'
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// * Assume device is a Wii U we've never seen before
|
||||
device = await Device.create({
|
||||
model: 'wup',
|
||||
device_id: deviceID,
|
||||
serial: serialNumber,
|
||||
linked_pids: [],
|
||||
certificate_hash: certificateHash
|
||||
});
|
||||
}
|
||||
|
||||
if (device.serial !== serialNumber) {
|
||||
// * Spoofed serial. Device ID compared to certificate directly earlier
|
||||
// TODO - Change this to a different error
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
error: {
|
||||
cause: 'Bad Request',
|
||||
code: '1600',
|
||||
message: 'Unable to process request'
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
request.device = device;
|
||||
} else {
|
||||
// * This should only be triggered on the Wii U for endpoints that don't send the device certificate,
|
||||
// * but can also be reached AFTER one has been seen (IE, after PNID creation/linking).
|
||||
// * This is generally considered safe since endpoints which fall into this category are used AFTER
|
||||
// * the Wii U has sent the device certificate to the server, so a valid entry should be made for it
|
||||
const device = await Device.findOne({
|
||||
device_id: deviceID,
|
||||
serial: serialNumber
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
cause: 'device_id',
|
||||
code: '0113',
|
||||
message: 'Unauthorized device'
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.access_level < 0) {
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
code: '0012',
|
||||
message: 'Device has been banned by game server' // TODO - This is not the right error message
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
request.device = device;
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
export default nnasCheckDeviceMiddleware;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import express from 'express';
|
||||
import xmlbuilder from 'xmlbuilder';
|
||||
import { getValueFromHeaders } from '@/util';
|
||||
import { getPNIDByBasicAuth, getPNIDByNNASAccessToken } from '@/database';
|
||||
import { getPNIDByBasicAuth, getPNIDByTokenAuth } from '@/database';
|
||||
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
|
||||
|
||||
async function PNIDMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): Promise<void> {
|
||||
|
|
@ -14,16 +14,16 @@ async function PNIDMiddleware(request: express.Request, response: express.Respon
|
|||
const parts = authHeader.split(' ');
|
||||
const type = parts[0];
|
||||
let token = parts[1];
|
||||
let pnid: HydratedPNIDDocument | null = null;
|
||||
let pnid: HydratedPNIDDocument | null;
|
||||
|
||||
if (request.isCemu) {
|
||||
token = Buffer.from(token, 'hex').toString('base64');
|
||||
}
|
||||
|
||||
if (type === 'Basic' && request.path.includes('v1/api/people/@me/devices')) {
|
||||
if (type === 'Basic') {
|
||||
pnid = await getPNIDByBasicAuth(token);
|
||||
} else if (type === 'Bearer') {
|
||||
pnid = await getPNIDByNNASAccessToken(token);
|
||||
} else {
|
||||
pnid = await getPNIDByTokenAuth(token);
|
||||
}
|
||||
|
||||
if (!pnid) {
|
||||
|
|
|
|||
|
|
@ -90,7 +90,10 @@ const PNIDSchema = new Schema<IPNID, PNIDModel, IPNIDMethods>({
|
|||
},
|
||||
devices: [DeviceSchema],
|
||||
identification: { // * user identification tokens
|
||||
email_code: String,
|
||||
email_code: {
|
||||
type: String,
|
||||
unique: true
|
||||
},
|
||||
email_token: {
|
||||
type: String,
|
||||
unique: true
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ const SIGNATURE_SIZES = {
|
|||
}
|
||||
} as const;
|
||||
|
||||
// TODO - Replace this with https://github.com/PretendoNetwork/nintendo-file-formats
|
||||
class NintendoCertificate {
|
||||
_certificate: Buffer;
|
||||
_certificateBody: Buffer;
|
||||
|
|
@ -124,16 +123,8 @@ class NintendoCertificate {
|
|||
const signatureTypeSizes = this._signatureTypeSizes(this.signatureType);
|
||||
|
||||
this._certificateBody = this._certificate.subarray(0x4 + signatureTypeSizes.SIZE + signatureTypeSizes.PADDING_SIZE);
|
||||
|
||||
this.signature = this._certificate.subarray(0x4, 0x4 + signatureTypeSizes.SIZE);
|
||||
|
||||
const padding = this._certificate.subarray(0x4 + signatureTypeSizes.SIZE, 0x4 + signatureTypeSizes.SIZE + signatureTypeSizes.PADDING_SIZE);
|
||||
|
||||
this.valid = padding.every(byte => byte === 0);
|
||||
|
||||
if (!this.valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.issuer = this._certificate.subarray(0x80, 0xC0).toString().split('\0')[0];
|
||||
this.keyType = this._certificate.readUInt32BE(0xC0);
|
||||
this.certificateName = this._certificate.subarray(0xC4, 0x104).toString().split('\0')[0];
|
||||
|
|
@ -146,7 +137,7 @@ class NintendoCertificate {
|
|||
this.consoleType = '3ds';
|
||||
}
|
||||
|
||||
this._verifySignatureECDSA(); // * Force it to use the expected certificate type
|
||||
this._verifySignature();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import express from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { getPNIDByUsername, getPNIDByAPIRefreshToken } from '@/database';
|
||||
import { nintendoPasswordHash, generateOAuthTokens} from '@/util';
|
||||
import { getPNIDByUsername, getPNIDByTokenAuth } from '@/database';
|
||||
import { nintendoPasswordHash, generateToken} from '@/util';
|
||||
import { config } from '@/config-manager';
|
||||
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
|
||||
import { SystemType } from '@/types/common/token';
|
||||
import { LOG_ERROR } from '@/logger';
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -87,7 +87,7 @@ router.post('/', async (request: express.Request, response: express.Response): P
|
|||
return;
|
||||
}
|
||||
} else {
|
||||
pnid = await getPNIDByAPIRefreshToken(refreshToken);
|
||||
pnid = await getPNIDByTokenAuth(refreshToken);
|
||||
|
||||
if (!pnid) {
|
||||
response.status(400).json({
|
||||
|
|
@ -110,19 +110,44 @@ router.post('/', async (request: express.Request, response: express.Response): P
|
|||
return;
|
||||
}
|
||||
|
||||
const accessTokenOptions = {
|
||||
system_type: 0x3, // * API
|
||||
token_type: 0x1, // * OAuth Access
|
||||
pid: pnid.pid,
|
||||
access_level: pnid.access_level,
|
||||
title_id: BigInt(0),
|
||||
expire_time: BigInt(Date.now() + (3600 * 1000))
|
||||
};
|
||||
|
||||
const refreshTokenOptions = {
|
||||
system_type: 0x3, // * API
|
||||
token_type: 0x2, // * OAuth Refresh
|
||||
pid: pnid.pid,
|
||||
access_level: pnid.access_level,
|
||||
title_id: BigInt(0),
|
||||
expire_time: BigInt(Date.now() + (3600 * 1000))
|
||||
};
|
||||
|
||||
try {
|
||||
const tokenGeneration = generateOAuthTokens(SystemType.API, pnid, { refreshExpiresIn: 14 * 24 * 60 * 60 }); // * 14 days
|
||||
|
||||
const accessTokenBuffer = await generateToken(config.aes_key, accessTokenOptions);
|
||||
const refreshTokenBuffer = await generateToken(config.aes_key, refreshTokenOptions);
|
||||
|
||||
const accessToken = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
|
||||
const newRefreshToken = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
|
||||
|
||||
// TODO - Handle null tokens
|
||||
|
||||
response.json({
|
||||
access_token: tokenGeneration.accessToken,
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: tokenGeneration.expiresInSecs.access,
|
||||
refresh_token: tokenGeneration.refreshToken
|
||||
expires_in: 3600,
|
||||
refresh_token: newRefreshToken
|
||||
});
|
||||
} catch (error: any) {
|
||||
LOG_ERROR('/v1/login - token generation: ' + error);
|
||||
if (error.stack) console.error(error.stack);
|
||||
|
||||
|
||||
response.status(500).json({
|
||||
app: 'api',
|
||||
status: 500,
|
||||
|
|
|
|||
|
|
@ -7,14 +7,13 @@ import moment from 'moment';
|
|||
import hcaptcha from 'hcaptcha';
|
||||
import Mii from 'mii-js';
|
||||
import { doesPNIDExist, connection as databaseConnection } from '@/database';
|
||||
import { nintendoPasswordHash, sendConfirmationEmail, generateOAuthTokens } from '@/util';
|
||||
import { nintendoPasswordHash, sendConfirmationEmail, generateToken } from '@/util';
|
||||
import { LOG_ERROR } from '@/logger';
|
||||
import { PNID } from '@/models/pnid';
|
||||
import { NEXAccount } from '@/models/nex-account';
|
||||
import { config, disabledFeatures } from '@/config-manager';
|
||||
import { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account';
|
||||
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
|
||||
import { SystemType } from '@/types/common/token';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -367,14 +366,38 @@ router.post('/', async (request: express.Request, response: express.Response): P
|
|||
|
||||
await sendConfirmationEmail(pnid);
|
||||
|
||||
const accessTokenOptions = {
|
||||
system_type: 0x3, // * API
|
||||
token_type: 0x1, // * OAuth Access
|
||||
pid: pnid.pid,
|
||||
access_level: pnid.access_level,
|
||||
title_id: BigInt(0),
|
||||
expire_time: BigInt(Date.now() + (3600 * 1000))
|
||||
};
|
||||
|
||||
const refreshTokenOptions = {
|
||||
system_type: 0x3, // * API
|
||||
token_type: 0x2, // * OAuth Refresh
|
||||
pid: pnid.pid,
|
||||
access_level: pnid.access_level,
|
||||
title_id: BigInt(0),
|
||||
expire_time: BigInt(Date.now() + (3600 * 1000))
|
||||
};
|
||||
|
||||
try {
|
||||
const tokenGeneration = generateOAuthTokens(SystemType.API, pnid, { refreshExpiresIn: 14 * 24 * 60 * 60 }); // * 14 days
|
||||
const accessTokenBuffer = await generateToken(config.aes_key, accessTokenOptions);
|
||||
const refreshTokenBuffer = await generateToken(config.aes_key, refreshTokenOptions);
|
||||
|
||||
const accessToken = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
|
||||
const refreshToken = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
|
||||
|
||||
// TODO - Handle null tokens
|
||||
|
||||
response.json({
|
||||
access_token: tokenGeneration.accessToken,
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: tokenGeneration.expiresInSecs.access,
|
||||
refresh_token: tokenGeneration.refreshToken
|
||||
expires_in: 3600,
|
||||
refresh_token: refreshToken
|
||||
});
|
||||
} catch (error: any) {
|
||||
LOG_ERROR('/v1/register - token generation: ' + error);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Status, ServerError } from 'nice-grpc';
|
||||
import { ExchangeTokenForUserDataRequest } from '@pretendonetwork/grpc/account/exchange_token_for_user_data';
|
||||
import { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc';
|
||||
import { getPNIDByAPIAccessToken } from '@/database';
|
||||
import { getPNIDByTokenAuth } from '@/database';
|
||||
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
|
||||
import { config } from '@/config-manager';
|
||||
|
||||
|
|
@ -10,7 +10,7 @@ export async function exchangeTokenForUserData(request: ExchangeTokenForUserData
|
|||
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
|
||||
}
|
||||
|
||||
const pnid = await getPNIDByAPIAccessToken(request.token);
|
||||
const pnid = await getPNIDByTokenAuth(request.token);
|
||||
|
||||
if (!pnid) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Status, ServerMiddlewareCall, CallContext, ServerError } from 'nice-grpc';
|
||||
import { getPNIDByAPIAccessToken } from '@/database';
|
||||
import { getPNIDByTokenAuth } from '@/database';
|
||||
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
|
||||
|
||||
// * These paths require that a token be present
|
||||
|
|
@ -30,7 +30,7 @@ export async function* authenticationMiddleware<Request, Response>(
|
|||
let pnid = null;
|
||||
|
||||
if (token) {
|
||||
pnid = await getPNIDByAPIAccessToken(token);
|
||||
pnid = await getPNIDByTokenAuth(token);
|
||||
}
|
||||
|
||||
if (!pnid && TOKEN_REQUIRED_PATHS.includes(call.method.path)) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { Status, ServerError } from 'nice-grpc';
|
||||
import { LoginRequest, LoginResponse, DeepPartial } from '@pretendonetwork/grpc/api/login_rpc';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { getPNIDByUsername, getPNIDByAPIRefreshToken } from '@/database';
|
||||
import { nintendoPasswordHash, generateOAuthTokens} from '@/util';
|
||||
import { getPNIDByUsername, getPNIDByTokenAuth } from '@/database';
|
||||
import { nintendoPasswordHash, generateToken} from '@/util';
|
||||
import { config } from '@/config-manager';
|
||||
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
|
||||
import { SystemType } from '@/types/common/token';
|
||||
|
||||
export async function login(request: LoginRequest): Promise<DeepPartial<LoginResponse>> {
|
||||
const grantType = request.grantType?.trim();
|
||||
|
|
@ -16,45 +16,74 @@ export async function login(request: LoginRequest): Promise<DeepPartial<LoginRes
|
|||
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid grant type');
|
||||
}
|
||||
|
||||
if (grantType === 'password' && !username) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing username');
|
||||
}
|
||||
|
||||
if (grantType === 'password' && !password) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing password');
|
||||
}
|
||||
|
||||
if (grantType === 'refresh_token' && !refreshToken) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing refresh token');
|
||||
}
|
||||
|
||||
let pnid: HydratedPNIDDocument | null;
|
||||
|
||||
if (grantType === 'password') {
|
||||
if (!username) throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing username');
|
||||
if (!password) throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing password');
|
||||
pnid = await getPNIDByUsername(username!); // * We know username will never be null here
|
||||
|
||||
pnid = await getPNIDByUsername(username);
|
||||
if (!pnid) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'User not found');
|
||||
}
|
||||
|
||||
if (!pnid) throw new ServerError(Status.INVALID_ARGUMENT, 'User not found');
|
||||
|
||||
const hashedPassword = nintendoPasswordHash(password, pnid.pid);
|
||||
const hashedPassword = nintendoPasswordHash(password!, pnid.pid); // * We know password will never be null here
|
||||
|
||||
if (!bcrypt.compareSync(hashedPassword, pnid.password)) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Password is incorrect');
|
||||
}
|
||||
} else if (grantType === 'refresh_token') {
|
||||
if (!refreshToken) throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing refresh token');
|
||||
|
||||
pnid = await getPNIDByAPIRefreshToken(refreshToken);
|
||||
|
||||
if (!pnid) throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing refresh token');
|
||||
} else {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid grant type');
|
||||
pnid = await getPNIDByTokenAuth(refreshToken!); // * We know refreshToken will never be null here
|
||||
|
||||
if (!pnid) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing refresh token');
|
||||
}
|
||||
}
|
||||
|
||||
if (pnid.deleted) {
|
||||
throw new ServerError(Status.UNAUTHENTICATED, 'Account has been deleted');
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenGeneration = generateOAuthTokens(SystemType.API, pnid, { refreshExpiresIn: 14 * 24 * 60 * 60 }); // * 14 days
|
||||
const accessTokenOptions = {
|
||||
system_type: 0x3, // * API
|
||||
token_type: 0x1, // * OAuth Access
|
||||
pid: pnid.pid,
|
||||
access_level: pnid.access_level,
|
||||
title_id: BigInt(0),
|
||||
expire_time: BigInt(Date.now() + (3600 * 1000))
|
||||
};
|
||||
|
||||
return {
|
||||
accessToken: tokenGeneration.accessToken,
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: tokenGeneration.expiresInSecs.access,
|
||||
refreshToken: tokenGeneration.refreshToken
|
||||
};
|
||||
} catch {
|
||||
throw new ServerError(Status.INTERNAL, 'Could not generate OAuth tokens');
|
||||
}
|
||||
const refreshTokenOptions = {
|
||||
system_type: 0x3, // * API
|
||||
token_type: 0x2, // * OAuth Refresh
|
||||
pid: pnid.pid,
|
||||
access_level: pnid.access_level,
|
||||
title_id: BigInt(0),
|
||||
expire_time: BigInt(Date.now() + (3600 * 1000))
|
||||
};
|
||||
|
||||
const accessTokenBuffer = await generateToken(config.aes_key, accessTokenOptions);
|
||||
const refreshTokenBuffer = await generateToken(config.aes_key, refreshTokenOptions);
|
||||
|
||||
const accessToken = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
|
||||
const newRefreshToken = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
|
||||
|
||||
// TODO - Handle null tokens
|
||||
|
||||
return {
|
||||
accessToken: accessToken,
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: 3600,
|
||||
refreshToken: newRefreshToken
|
||||
};
|
||||
}
|
||||
|
|
@ -8,14 +8,13 @@ import moment from 'moment';
|
|||
import hcaptcha from 'hcaptcha';
|
||||
import Mii from 'mii-js';
|
||||
import { doesPNIDExist, connection as databaseConnection } from '@/database';
|
||||
import { nintendoPasswordHash, sendConfirmationEmail, generateOAuthTokens } from '@/util';
|
||||
import { nintendoPasswordHash, sendConfirmationEmail, generateToken } from '@/util';
|
||||
import { LOG_ERROR } from '@/logger';
|
||||
import { PNID } from '@/models/pnid';
|
||||
import { NEXAccount } from '@/models/nex-account';
|
||||
import { config, disabledFeatures } from '@/config-manager';
|
||||
import type { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account';
|
||||
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
|
||||
import { SystemType } from '@/types/common/token';
|
||||
|
||||
const PNID_VALID_CHARACTERS_REGEX = /^[\w\-.]*$/;
|
||||
const PNID_PUNCTUATION_START_REGEX = /^[_\-.]/;
|
||||
|
|
@ -230,16 +229,36 @@ export async function register(request: RegisterRequest): Promise<DeepPartial<Lo
|
|||
|
||||
await sendConfirmationEmail(pnid);
|
||||
|
||||
try {
|
||||
const tokenGeneration = generateOAuthTokens(SystemType.API, pnid, { refreshExpiresIn: 14 * 24 * 60 * 60 }); // * 14 days
|
||||
const accessTokenOptions = {
|
||||
system_type: 0x3, // * API
|
||||
token_type: 0x1, // * OAuth Access
|
||||
pid: pnid.pid,
|
||||
access_level: pnid.access_level,
|
||||
title_id: BigInt(0),
|
||||
expire_time: BigInt(Date.now() + (3600 * 1000))
|
||||
};
|
||||
|
||||
return {
|
||||
accessToken: tokenGeneration.accessToken,
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: tokenGeneration.expiresInSecs.access,
|
||||
refreshToken: tokenGeneration.refreshToken
|
||||
};
|
||||
} catch {
|
||||
throw new ServerError(Status.INTERNAL, 'Could not generate OAuth tokens');
|
||||
}
|
||||
const refreshTokenOptions = {
|
||||
system_type: 0x3, // * API
|
||||
token_type: 0x2, // * OAuth Refresh
|
||||
pid: pnid.pid,
|
||||
access_level: pnid.access_level,
|
||||
title_id: BigInt(0),
|
||||
expire_time: BigInt(Date.now() + (3600 * 1000))
|
||||
};
|
||||
|
||||
const accessTokenBuffer = await generateToken(config.aes_key, accessTokenOptions);
|
||||
const refreshTokenBuffer = await generateToken(config.aes_key, refreshTokenOptions);
|
||||
|
||||
const accessToken = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
|
||||
const refreshToken = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
|
||||
|
||||
// TODO - Handle null tokens
|
||||
|
||||
return {
|
||||
accessToken: accessToken,
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: 3600,
|
||||
refreshToken: refreshToken
|
||||
};
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ import { nintendoBase64Encode, nintendoBase64Decode, nascDateTime, nascError, ge
|
|||
import { getServerByTitleID } from '@/database';
|
||||
import { NASCRequestParams } from '@/types/services/nasc/request-params';
|
||||
import { HydratedServerDocument } from '@/types/mongoose/server';
|
||||
import { SystemType, TokenOptions, TokenType } from '@/types/common/token';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -65,17 +64,19 @@ router.post('/', async (request: express.Request, response: express.Response): P
|
|||
});
|
||||
|
||||
async function processLoginRequest(server: HydratedServerDocument, pid: number, titleID: string): Promise<URLSearchParams> {
|
||||
const tokenOptions: TokenOptions = {
|
||||
system_type: SystemType['3DS'],
|
||||
token_type: TokenType.NEX,
|
||||
const tokenOptions = {
|
||||
system_type: 0x2, // * 3DS
|
||||
token_type: 0x3, // * NEX token
|
||||
pid: pid,
|
||||
access_level: 0,
|
||||
title_id: BigInt(parseInt(titleID, 16)),
|
||||
expire_time: BigInt(Date.now() + (3600 * 1000))
|
||||
};
|
||||
|
||||
const nexTokenBuffer = generateToken(server.aes_key, tokenOptions);
|
||||
const nexToken = nintendoBase64Encode(nexTokenBuffer);
|
||||
// TODO - Handle null tokens
|
||||
|
||||
const nexTokenBuffer = await generateToken(server.aes_key, tokenOptions);
|
||||
const nexToken = nintendoBase64Encode(nexTokenBuffer || '');
|
||||
|
||||
return new URLSearchParams({
|
||||
locator: nintendoBase64Encode(`${server.ip}:${server.port}`),
|
||||
|
|
@ -87,17 +88,19 @@ async function processLoginRequest(server: HydratedServerDocument, pid: number,
|
|||
}
|
||||
|
||||
async function processServiceTokenRequest(server: HydratedServerDocument, pid: number, titleID: string): Promise<URLSearchParams> {
|
||||
const tokenOptions: TokenOptions = {
|
||||
system_type: SystemType['3DS'],
|
||||
token_type: TokenType.SERVICE,
|
||||
const tokenOptions = {
|
||||
system_type: 0x2, // * 3DS
|
||||
token_type: 0x4, // * Service token
|
||||
pid: pid,
|
||||
access_level: 0,
|
||||
title_id: BigInt(parseInt(titleID, 16)),
|
||||
expire_time: BigInt(Date.now() + (3600 * 1000))
|
||||
};
|
||||
|
||||
const serviceTokenBuffer = generateToken(server.aes_key, tokenOptions);
|
||||
const serviceToken = nintendoBase64Encode(serviceTokenBuffer);
|
||||
// TODO - Handle null tokens
|
||||
|
||||
const serviceTokenBuffer = await generateToken(server.aes_key, tokenOptions);
|
||||
const serviceToken = nintendoBase64Encode(serviceTokenBuffer || '');
|
||||
|
||||
return new URLSearchParams({
|
||||
retry: nintendoBase64Encode('0'),
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
import path from 'node:path';
|
||||
import express from 'express';
|
||||
import nnasBasicHeaderCheckMiddleware from '@/middleware/nnas-basic-header-check';
|
||||
import nnasCheckDeviceMiddleware from '@/middleware/nnas-check-device';
|
||||
import clientHeaderCheck from '@/middleware/client-header';
|
||||
import cemuMiddleware from '@/middleware/cemu';
|
||||
import pnidMiddleware from '@/middleware/pnid';
|
||||
import { LOG_INFO, formatHostnames } from '@/logger';
|
||||
|
|
@ -47,8 +46,7 @@ nnas.use('/v1/account-settings/js/', setJSHeader, express.static(path.join(__dir
|
|||
nnas.use('/v1/account-settings/img/', setIMGHeader, express.static(path.join(__dirname, '../../assets/user-info-settings')));
|
||||
|
||||
LOG_INFO('[NNAS] Importing middleware');
|
||||
nnas.use(nnasBasicHeaderCheckMiddleware);
|
||||
nnas.use(nnasCheckDeviceMiddleware);
|
||||
nnas.use(clientHeaderCheck);
|
||||
nnas.use(cemuMiddleware);
|
||||
nnas.use(pnidMiddleware);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import express from 'express';
|
||||
import xmlbuilder from 'xmlbuilder';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { getPNIDByNNASRefreshToken, getPNIDByUsername } from '@/database';
|
||||
import { generateOAuthTokens } from '@/util';
|
||||
import deviceCertificateMiddleware from '@/middleware/device-certificate';
|
||||
import consoleStatusVerificationMiddleware from '@/middleware/console-status-verification';
|
||||
import { getPNIDByTokenAuth, getPNIDByUsername } from '@/database';
|
||||
import { generateToken } from '@/util';
|
||||
import { config } from '@/config-manager';
|
||||
import { Device } from '@/models/device';
|
||||
import { SystemType } from '@/types/common/token';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -13,7 +15,7 @@ const router = express.Router();
|
|||
* Replacement for: https://account.nintendo.net/v1/api/oauth20/access_token/generate
|
||||
* Description: Generates an access token for a user
|
||||
*/
|
||||
router.post('/access_token/generate', async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
router.post('/access_token/generate', deviceCertificateMiddleware, consoleStatusVerificationMiddleware, async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
const grantType = request.body.grant_type;
|
||||
const username = request.body.user_id;
|
||||
const password = request.body.password;
|
||||
|
|
@ -86,7 +88,7 @@ router.post('/access_token/generate', async (request: express.Request, response:
|
|||
}
|
||||
|
||||
try {
|
||||
pnid = await getPNIDByNNASRefreshToken(refreshToken);
|
||||
pnid = await getPNIDByTokenAuth(refreshToken);
|
||||
|
||||
if (!pnid) {
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
|
|
@ -151,21 +153,37 @@ router.post('/access_token/generate', async (request: express.Request, response:
|
|||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenGeneration = generateOAuthTokens(SystemType.WIIU, pnid);
|
||||
const accessTokenOptions = {
|
||||
system_type: 0x1, // * WiiU
|
||||
token_type: 0x1, // * OAuth Access
|
||||
pid: pnid.pid,
|
||||
expire_time: BigInt(Date.now() + (3600 * 1000))
|
||||
};
|
||||
|
||||
response.send(xmlbuilder.create({
|
||||
OAuth20: {
|
||||
access_token: {
|
||||
token: tokenGeneration.accessToken,
|
||||
refresh_token: tokenGeneration.refreshToken,
|
||||
expires_in: tokenGeneration.expiresInSecs.access
|
||||
}
|
||||
const refreshTokenOptions = {
|
||||
system_type: 0x1, // * WiiU
|
||||
token_type: 0x2, // * OAuth Refresh
|
||||
pid: pnid.pid,
|
||||
expire_time: BigInt(Date.now() + (3600 * 1000))
|
||||
};
|
||||
|
||||
const accessTokenBuffer = await generateToken(config.aes_key, accessTokenOptions);
|
||||
const refreshTokenBuffer = await generateToken(config.aes_key, refreshTokenOptions);
|
||||
|
||||
const accessToken = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
|
||||
const newRefreshToken = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
|
||||
|
||||
// TODO - Handle null tokens
|
||||
|
||||
response.send(xmlbuilder.create({
|
||||
OAuth20: {
|
||||
access_token: {
|
||||
token: accessToken,
|
||||
refresh_token: newRefreshToken,
|
||||
expires_in: 3600
|
||||
}
|
||||
}).commentBefore('WARNING! DO NOT SHARE ANYTHING IN THIS REQUEST OR RESPONSE WITH UNTRUSTED USERS! IT CAN BE USED TO IMPERSONATE YOU AND YOUR CONSOLE, POTENTIALLY GETTING YOU BANNED!').end()); // TODO - This is ugly
|
||||
} catch {
|
||||
response.status(500);
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -3,6 +3,7 @@ import express from 'express';
|
|||
import xmlbuilder from 'xmlbuilder';
|
||||
import bcrypt from 'bcrypt';
|
||||
import moment from 'moment';
|
||||
import deviceCertificateMiddleware from '@/middleware/device-certificate';
|
||||
import ratelimit from '@/middleware/ratelimit';
|
||||
import { connection as databaseConnection, doesPNIDExist, getPNIDProfileJSONByPID } from '@/database';
|
||||
import { getValueFromHeaders, nintendoPasswordHash, sendConfirmationEmail, sendPNIDDeletedEmail } from '@/util';
|
||||
|
|
@ -48,7 +49,20 @@ router.get('/:username', async (request: express.Request, response: express.Resp
|
|||
* Replacement for: https://account.nintendo.net/v1/api/people
|
||||
* Description: Registers a new NNID
|
||||
*/
|
||||
router.post('/', ratelimit, async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
router.post('/', ratelimit, deviceCertificateMiddleware, async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
if (!request.certificate || !request.certificate.valid) {
|
||||
// TODO - Change this to a different error
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
error: {
|
||||
cause: 'Bad Request',
|
||||
code: '1600',
|
||||
message: 'Unable to process request'
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const person: Person = request.body.person;
|
||||
|
||||
const userExists = await doesPNIDExist(person.user_id);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import express from 'express';
|
||||
import xmlbuilder from 'xmlbuilder';
|
||||
import { getServerByClientID, getServerByGameServerID } from '@/database';
|
||||
import { generateToken, getValueFromHeaders, getValueFromQueryString, isSystemType } from '@/util';
|
||||
import { generateToken, getValueFromHeaders, getValueFromQueryString } from '@/util';
|
||||
import { NEXAccount } from '@/models/nex-account';
|
||||
import { SystemType, TokenOptions, TokenType } from '@/types/common/token';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -90,24 +89,21 @@ router.get('/service_token/@me', async (request: express.Request, response: expr
|
|||
return;
|
||||
}
|
||||
|
||||
if (!isSystemType(server.device)) {
|
||||
throw new Error('Invalid system type');
|
||||
}
|
||||
|
||||
// * Asserted safely because of the check above
|
||||
const systemType = server.device as SystemType;
|
||||
|
||||
const tokenOptions: TokenOptions = {
|
||||
system_type: systemType as SystemType,
|
||||
token_type: TokenType.SERVICE,
|
||||
const tokenOptions = {
|
||||
system_type: server.device,
|
||||
token_type: 0x4, // * Service token
|
||||
pid: pnid.pid,
|
||||
access_level: pnid.access_level,
|
||||
title_id: BigInt(parseInt(titleID, 16)),
|
||||
expire_time: BigInt(Date.now() + (3600 * 1000))
|
||||
};
|
||||
|
||||
const serviceTokenBuffer = generateToken(server.aes_key, tokenOptions);
|
||||
const serviceToken = request.isCemu ? serviceTokenBuffer.toString('hex') : serviceTokenBuffer.toString('base64');
|
||||
const serviceTokenBuffer = await generateToken(server.aes_key, tokenOptions);
|
||||
let serviceToken = serviceTokenBuffer ? serviceTokenBuffer.toString('base64') : '';
|
||||
|
||||
if (request.isCemu) {
|
||||
serviceToken = Buffer.from(serviceToken, 'base64').toString('hex');
|
||||
}
|
||||
|
||||
response.send(xmlbuilder.create({
|
||||
service_token: {
|
||||
|
|
@ -216,23 +212,16 @@ router.get('/nex_token/@me', async (request: express.Request, response: express.
|
|||
return;
|
||||
}
|
||||
|
||||
if (!isSystemType(server.device)) {
|
||||
throw new Error('Invalid system type');
|
||||
}
|
||||
|
||||
// * Asserted safely because of the check above
|
||||
const systemType = server.device as SystemType;
|
||||
|
||||
const tokenOptions: TokenOptions = {
|
||||
system_type: systemType,
|
||||
token_type: TokenType.NEX,
|
||||
const tokenOptions = {
|
||||
system_type: server.device,
|
||||
token_type: 0x3, // * nex token,
|
||||
pid: pnid.pid,
|
||||
access_level: pnid.access_level,
|
||||
title_id: BigInt(parseInt(titleID, 16)),
|
||||
expire_time: BigInt(Date.now() + (3600 * 1000))
|
||||
};
|
||||
|
||||
const nexTokenBuffer = generateToken(server.aes_key, tokenOptions);
|
||||
const nexTokenBuffer = await generateToken(server.aes_key, tokenOptions);
|
||||
let nexToken = nexTokenBuffer ? nexTokenBuffer.toString('base64') : '';
|
||||
|
||||
if (request.isCemu) {
|
||||
|
|
|
|||
|
|
@ -3,57 +3,8 @@ import express from 'express';
|
|||
import xmlbuilder from 'xmlbuilder';
|
||||
import moment from 'moment';
|
||||
import { getPNIDByEmailAddress, getPNIDByPID } from '@/database';
|
||||
import { Device } from '@/models/device';
|
||||
import { sendEmailConfirmedEmail, sendConfirmationEmail, sendForgotPasswordEmail, sendEmailConfirmedParentalControlsEmail } from '@/util';
|
||||
|
||||
// * Middleware to ensure the input device is valid
|
||||
// TODO - Make this available for more routes? This could be useful elsewhere
|
||||
async function validateDeviceIDMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): Promise<void> {
|
||||
const deviceID = request.header('x-nintendo-device-id');
|
||||
const serial = request.header('x-nintendo-serial-number');
|
||||
|
||||
// * Since these values are linked at the time of device creation, and the
|
||||
// * device ID is always validated against the device certificate for legitimacy
|
||||
// * we can safely assume that every console hitting our servers through normal
|
||||
// * means will be stored correctly. And since once both values are set they
|
||||
// * cannot be changed, these checks will always be safe
|
||||
const device = await Device.findOne({
|
||||
device_id: Number(deviceID),
|
||||
serial: serial
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
cause: 'device_id',
|
||||
code: '0113',
|
||||
message: 'Unauthorized device'
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.access_level < 0) {
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
code: '0012',
|
||||
message: 'Device has been banned by game server' // TODO - This is not the right error message
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO - Once we push support for linking PNIDs to consoles, also check if the PID is linked or not
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
|
|
@ -120,13 +71,6 @@ router.put('/email_confirmation/:pid/:code', async (request: express.Request, re
|
|||
return;
|
||||
}
|
||||
|
||||
// * If the email is already confirmed don't bother continuing
|
||||
if (pnid.email.validated) {
|
||||
// TODO - Is there an actual error for this case?
|
||||
response.status(200).send('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (pnid.identification.email_code !== code) {
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
errors: {
|
||||
|
|
@ -157,7 +101,7 @@ router.put('/email_confirmation/:pid/:code', async (request: express.Request, re
|
|||
* Replacement for: https://account.nintendo.net/v1/api/support/resend_confirmation
|
||||
* Description: Resends a users confirmation email
|
||||
*/
|
||||
router.get('/resend_confirmation', validateDeviceIDMiddleware, async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
router.get('/resend_confirmation', async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
const pid = Number(request.headers['x-nintendo-pid']);
|
||||
|
||||
const pnid = await getPNIDByPID(pid);
|
||||
|
|
@ -176,13 +120,6 @@ router.get('/resend_confirmation', validateDeviceIDMiddleware, async (request: e
|
|||
return;
|
||||
}
|
||||
|
||||
// * If the email is already confirmed don't bother continuing
|
||||
if (pnid.email.validated) {
|
||||
// TODO - Is there an actual error for this case?
|
||||
response.status(200).send('');
|
||||
return;
|
||||
}
|
||||
|
||||
await sendConfirmationEmail(pnid);
|
||||
|
||||
response.status(200).send('');
|
||||
|
|
@ -223,15 +160,19 @@ router.get('/send_confirmation/pin/:email', async (request: express.Request, res
|
|||
* 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', validateDeviceIDMiddleware, async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
if (!/^\d+$/.test(request.params.pid)) {
|
||||
// * This is what Nintendo sends
|
||||
router.get('/forgotten_password/:pid', async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
const pid = Number(request.params.pid);
|
||||
|
||||
const pnid = await getPNIDByPID(pid);
|
||||
|
||||
if (!pnid) {
|
||||
// TODO - Better errors
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
cause: 'Not Found',
|
||||
code: '1600',
|
||||
message: 'Unable to process request'
|
||||
cause: 'device_id',
|
||||
code: '0113',
|
||||
message: 'Unauthorized device'
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
|
@ -239,16 +180,6 @@ router.get('/forgotten_password/:pid', validateDeviceIDMiddleware, async (reques
|
|||
return;
|
||||
}
|
||||
|
||||
const pid = Number(request.params.pid);
|
||||
const pnid = await getPNIDByPID(pid);
|
||||
|
||||
if (!pnid) {
|
||||
// * Whenever a PID is a number, but is invalid, Nintendo just 404s
|
||||
// TODO - When we move to linking PNIDs to consoles, this also applies to valid PIDs not linked to the current console
|
||||
response.status(404).send('');
|
||||
return;
|
||||
}
|
||||
|
||||
await sendForgotPasswordEmail(pnid);
|
||||
|
||||
response.status(200).send('');
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
8
src/types/common/token-options.ts
Normal file
8
src/types/common/token-options.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export interface TokenOptions {
|
||||
system_type: number;
|
||||
token_type: number;
|
||||
pid: number;
|
||||
access_level?: number;
|
||||
title_id?: bigint;
|
||||
expire_time: bigint;
|
||||
}
|
||||
|
|
@ -1,51 +1,8 @@
|
|||
export const TokenType = {
|
||||
OAUTH_ACCESS: 1,
|
||||
OAUTH_REFRESH: 2,
|
||||
NEX: 3,
|
||||
SERVICE: 4,
|
||||
PASSWORD_RESET: 5
|
||||
} as const;
|
||||
export type TokenType = typeof TokenType[keyof typeof TokenType];
|
||||
|
||||
export const SystemType = {
|
||||
'WIIU': 1,
|
||||
'3DS': 2,
|
||||
'API': 3
|
||||
} as const;
|
||||
export type SystemType = typeof SystemType[keyof typeof SystemType];
|
||||
|
||||
export interface Token {
|
||||
system_type: SystemType;
|
||||
token_type: TokenType;
|
||||
system_type: number;
|
||||
token_type: number;
|
||||
pid: number;
|
||||
access_level?: number;
|
||||
title_id?: bigint;
|
||||
expire_time: bigint;
|
||||
}
|
||||
|
||||
// ? Separated so additional non-token fields can be added in the future
|
||||
export type TokenOptions = Token & {
|
||||
access_level: number;
|
||||
}
|
||||
|
||||
export type OAuthTokenGenerationResponse = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresInSecs: {
|
||||
access: number;
|
||||
refresh: number;
|
||||
}
|
||||
};
|
||||
|
||||
export type OAuthTokenOptions = {
|
||||
/**
|
||||
* The number of seconds the access token will be valid for, defaults to 1 hour
|
||||
* @default 60 * 60
|
||||
*/
|
||||
accessExpiresIn?: number;
|
||||
/**
|
||||
* The number of seconds the refresh token will be valid for, defaults to 14 days
|
||||
* @default 14 * 24 * 60 * 60
|
||||
*/
|
||||
refreshExpiresIn?: number;
|
||||
}
|
||||
81
src/util.ts
81
src/util.ts
|
|
@ -10,8 +10,9 @@ import crc32 from 'buffer-crc32';
|
|||
import crc from 'crc';
|
||||
import { sendMail } from '@/mailer';
|
||||
import { config, disabledFeatures } from '@/config-manager';
|
||||
import { OAuthTokenGenerationResponse, OAuthTokenOptions, SystemType, Token, TokenOptions, TokenType } from '@/types/common/token';
|
||||
import { HydratedPNIDDocument, IPNID, IPNIDMethods } from '@/types/mongoose/pnid';
|
||||
import { TokenOptions } from '@/types/common/token-options';
|
||||
import { Token } from '@/types/common/token';
|
||||
import { IPNID, IPNIDMethods } from '@/types/mongoose/pnid';
|
||||
import { SafeQs } from '@/types/common/safe-qs';
|
||||
|
||||
let s3: S3;
|
||||
|
|
@ -52,49 +53,7 @@ export function nintendoBase64Encode(decoded: string | Buffer): string {
|
|||
return encoded.replaceAll('+', '.').replaceAll('/', '-').replaceAll('=', '*');
|
||||
}
|
||||
|
||||
export function generateOAuthTokens(systemType: SystemType, pnid: HydratedPNIDDocument, options?: OAuthTokenOptions): OAuthTokenGenerationResponse {
|
||||
const accessTokenExpiresInSecs = options?.accessExpiresIn ?? 60 * 60; // * 1 hour
|
||||
const refreshTokenExpiresInSecs = options?.refreshExpiresIn ?? 24 * 60 * 60; // * 24 hours
|
||||
|
||||
const accessTokenOptions: TokenOptions = {
|
||||
system_type: systemType,
|
||||
token_type: TokenType.OAUTH_ACCESS,
|
||||
pid: pnid.pid,
|
||||
access_level: pnid.access_level,
|
||||
expire_time: BigInt(Date.now() + (accessTokenExpiresInSecs * 1000))
|
||||
};
|
||||
|
||||
const refreshTokenOptions: TokenOptions = {
|
||||
system_type: systemType,
|
||||
token_type: TokenType.OAUTH_REFRESH,
|
||||
pid: pnid.pid,
|
||||
access_level: pnid.access_level,
|
||||
expire_time: BigInt(Date.now() + (refreshTokenExpiresInSecs * 1000))
|
||||
};
|
||||
|
||||
const accessToken = generateToken(config.aes_key, accessTokenOptions).toString('hex');
|
||||
const refreshToken = generateToken(config.aes_key, refreshTokenOptions).toString('hex');
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresInSecs: {
|
||||
access: accessTokenExpiresInSecs,
|
||||
refresh: refreshTokenExpiresInSecs
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function isSystemType(value: number): value is SystemType {
|
||||
return (Object.values(SystemType) as number[]).includes(value);
|
||||
}
|
||||
|
||||
export function isTokenType(value: number): value is TokenType {
|
||||
return (Object.values(TokenType) as number[]).includes(value);
|
||||
}
|
||||
|
||||
|
||||
export function generateToken(key: string, options: TokenOptions): Buffer {
|
||||
export function generateToken(key: string, options: TokenOptions): Buffer | null {
|
||||
let dataBuffer = Buffer.alloc(1 + 1 + 4 + 8);
|
||||
|
||||
dataBuffer.writeUInt8(options.system_type, 0x0);
|
||||
|
|
@ -102,16 +61,19 @@ export function generateToken(key: string, options: TokenOptions): Buffer {
|
|||
dataBuffer.writeUInt32LE(options.pid, 0x2);
|
||||
dataBuffer.writeBigUInt64LE(options.expire_time, 0x6);
|
||||
|
||||
if ((options.token_type !== TokenType.OAUTH_ACCESS && options.token_type !== TokenType.OAUTH_REFRESH) || options.system_type === SystemType.API) {
|
||||
if ((options.token_type !== 0x1 && options.token_type !== 0x2) || options.system_type === 0x3) {
|
||||
// * Access and refresh tokens have smaller bodies due to size constraints
|
||||
// * The API does not have this restraint, however
|
||||
if (options.title_id === undefined || options.access_level === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
dataBuffer = Buffer.concat([
|
||||
dataBuffer,
|
||||
Buffer.alloc(8 + 1)
|
||||
]);
|
||||
|
||||
dataBuffer.writeBigUInt64LE(options.title_id ?? BigInt(0), 0xE);
|
||||
dataBuffer.writeBigUInt64LE(options.title_id, 0xE);
|
||||
dataBuffer.writeInt8(options.access_level, 0x16);
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +87,7 @@ export function generateToken(key: string, options: TokenOptions): Buffer {
|
|||
|
||||
let final = encrypted;
|
||||
|
||||
if ((options.token_type !== TokenType.OAUTH_ACCESS && options.token_type !== TokenType.OAUTH_REFRESH) || options.system_type === SystemType.API) {
|
||||
if ((options.token_type !== 0x1 && options.token_type !== 0x2) || options.system_type === 0x3) {
|
||||
// * Access and refresh tokens don't get a checksum due to size constraints
|
||||
const checksum = crc32(dataBuffer);
|
||||
|
||||
|
|
@ -170,20 +132,14 @@ export function decryptToken(token: Buffer, key?: string): Buffer {
|
|||
}
|
||||
|
||||
export function unpackToken(token: Buffer): Token {
|
||||
const systemType = token.readUInt8(0x0);
|
||||
const tokenType = token.readUInt8(0x1);
|
||||
|
||||
if (!isSystemType(systemType)) throw new Error('Invalid system type');
|
||||
if (!isTokenType(tokenType)) throw new Error('Invalid token type');
|
||||
|
||||
const unpacked: Token = {
|
||||
system_type: systemType,
|
||||
token_type: tokenType,
|
||||
system_type: token.readUInt8(0x0),
|
||||
token_type: token.readUInt8(0x1),
|
||||
pid: token.readUInt32LE(0x2),
|
||||
expire_time: token.readBigUInt64LE(0x6)
|
||||
};
|
||||
|
||||
if (unpacked.token_type !== TokenType.OAUTH_ACCESS && unpacked.token_type !== TokenType.OAUTH_REFRESH) {
|
||||
if (unpacked.token_type !== 0x1 && unpacked.token_type !== 0x2) {
|
||||
unpacked.title_id = token.readBigUInt64LE(0xE);
|
||||
unpacked.access_level = token.readInt8(0x16);
|
||||
}
|
||||
|
|
@ -281,16 +237,19 @@ export async function sendEmailConfirmedParentalControlsEmail(pnid: mongoose.Hyd
|
|||
}
|
||||
|
||||
export async function sendForgotPasswordEmail(pnid: mongoose.HydratedDocument<IPNID, IPNIDMethods>): Promise<void> {
|
||||
const tokenOptions: TokenOptions = {
|
||||
system_type: SystemType.API,
|
||||
token_type: TokenType.PASSWORD_RESET,
|
||||
const tokenOptions = {
|
||||
system_type: 0xF, // * API
|
||||
token_type: 0x5, // * Password reset
|
||||
pid: pnid.pid,
|
||||
access_level: pnid.access_level,
|
||||
title_id: BigInt(0),
|
||||
expire_time: BigInt(Date.now() + (24 * 60 * 60 * 1000)) // * Only valid for 24 hours
|
||||
};
|
||||
|
||||
const passwordResetToken = generateToken(config.aes_key, tokenOptions).toString('hex');
|
||||
const tokenBuffer = await generateToken(config.aes_key, tokenOptions);
|
||||
const passwordResetToken = tokenBuffer ? tokenBuffer.toString('hex') : '';
|
||||
|
||||
// TODO - Handle null token
|
||||
|
||||
const mailerOptions = {
|
||||
to: pnid.email.address,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user