Merge pull request #313 from PretendoNetwork/feat/grpc-tokens
Some checks are pending
Build and Publish Docker Image / Build and Publish Docker Image (amd64) (push) Waiting to run
Build and Publish Docker Image / Build and Publish Docker Image (arm64) (push) Waiting to run

Opaque tokens
This commit is contained in:
Jonathan Barrow
2026-07-17 16:12:25 -04:00
committed by GitHub
61 changed files with 2870 additions and 2803 deletions

3439
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,8 @@
"description": "",
"main": "./dist/server.js",
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"lint": "eslint ./src",
"lint:fix": "eslint --fix ./src",
"download-geoip-db": "node ./scripts/download-ip2location-databases.js",
"build": "npm run lint && npm run clean && npx tsc && npx tsc-alias && npm run copy-static && npm run download-geoip-db",
"clean": "rimraf ./dist",
@@ -90,5 +90,8 @@
"globals": "^15.14.0",
"ndarray": "^1.0.19",
"typescript": "^4.9.5"
},
"overrides": {
"@bufbuild/protobuf": "2.2.2"
}
}

View File

@@ -1,7 +1,9 @@
import crypto from 'node:crypto';
import mongoose from 'mongoose';
import bcrypt from 'bcrypt';
import joi from 'joi';
import { nintendoPasswordHash, decryptToken, unpackToken } from '@/util';
import { nintendoPasswordHash } from '@/util';
import { OAuthToken } from '@/models/oauth-token';
import { PNID } from '@/models/pnid';
import { Server } from '@/models/server';
import { LOG_ERROR } from '@/logger';
@@ -110,26 +112,37 @@ async function getPNIDByOAuthToken(token: string, expectedSystemType: SystemType
verifyConnected();
try {
const decryptedToken = decryptToken(Buffer.from(token, 'hex'));
const unpackedToken = unpackToken(decryptedToken);
const oauthToken = await OAuthToken.findOne({
token: crypto.createHash('sha256').update(token).digest('hex')
});
if (unpackedToken.system_type !== expectedSystemType) {
return null;
}
if (unpackedToken.token_type !== expectedTokenType) {
if (!oauthToken) {
return null;
}
const pnid = await getPNIDByPID(unpackedToken.pid);
if (oauthToken.info.system_type !== expectedSystemType) {
return null;
}
if (oauthToken.info.token_type !== expectedTokenType) {
return null;
}
const pnid = await getPNIDByPID(oauthToken.pid);
if (pnid) {
const expireTime = Math.floor((Number(unpackedToken.expire_time) / 1000));
const expireTime = Math.floor((Number(oauthToken.info.expires) / 1000));
if (Math.floor(Date.now() / 1000) > expireTime) {
return null;
}
}
// * Refresh tokens are single use
if (expectedTokenType === TokenType.OAuthRefresh) {
await oauthToken.deleteOne();
}
return pnid;
} catch (error: any) {
// TODO - Handle error

View File

@@ -2,6 +2,7 @@ import { getValueFromHeaders } from '@/util';
import { getPNIDByAPIAccessToken } from '@/database';
import { LOG_ERROR } from '@/logger';
import type express from 'express';
async function APIMiddleware(request: express.Request, _response: express.Response, next: express.NextFunction): Promise<void> {
const authHeader = getValueFromHeaders(request.headers, 'authorization');

View File

@@ -6,10 +6,10 @@ import { connection as databaseConnection } from '@/database';
import NintendoCertificate from '@/nintendo-certificate';
import { LOG_ERROR } from '@/logger';
import type express from 'express';
import type { NASCRequestParams } from '@/types/services/nasc/request-params';
import type { NASCACRequestParams } from '@/types/services/nasc/ac-request-params';
async function NASCMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): Promise<void> {
const requestParams: NASCRequestParams = request.body;
const requestParams: NASCACRequestParams = request.body;
if (!requestParams.action ||
!requestParams.fcdcert ||
@@ -37,15 +37,15 @@ async function NASCMiddleware(request: express.Request, response: express.Respon
let pidHmac = '';
let password = '';
if (requestParams.userid) {
if ('userid' in requestParams) {
pid = Number(nintendoBase64Decode(requestParams.userid).toString());
}
if (requestParams.uidhmac) {
if ('uidhmac' in requestParams) {
pidHmac = nintendoBase64Decode(requestParams.uidhmac).toString();
}
if (requestParams.passwd) {
if ('passwd' in requestParams) {
password = nintendoBase64Decode(requestParams.passwd).toString();
}

View File

@@ -0,0 +1,45 @@
import { Schema, model } from 'mongoose';
import type { Model, HydratedDocument } from 'mongoose';
// TODO - Move these to the types folder, just putting them here to push this out the door
export interface IIndependentServiceToken {
token: string;
client_id: string;
title_id: string;
pid: number;
info: {
system_type: number;
token_type: number;
title_id: bigint;
issued: Date;
expires: Date;
};
}
export interface IIndependentServiceTokenMethods {}
interface IIndependentServiceTokenQueryHelpers {}
export interface IndependentServiceTokenModel extends Model<IIndependentServiceToken, IIndependentServiceTokenQueryHelpers, IIndependentServiceTokenMethods> {}
export type HydratedIndependentServiceTokenDocument = HydratedDocument<IIndependentServiceToken, IIndependentServiceTokenMethods>;
const IndependentServiceTokenSchema = new Schema<IIndependentServiceToken, IndependentServiceTokenModel, IIndependentServiceTokenMethods>({
token: String,
client_id: String,
title_id: String,
pid: Number,
info: {
system_type: Number,
token_type: Number,
title_id: BigInt,
issued: Date,
expires: Date
}
});
IndependentServiceTokenSchema.index({ 'info.expires': 1 }, { expireAfterSeconds: 0 });
IndependentServiceTokenSchema.index({ token: 1 });
IndependentServiceTokenSchema.index({ pid: 1 });
export const IndependentServiceToken = model<IIndependentServiceToken, IndependentServiceTokenModel>('IndependentServiceToken', IndependentServiceTokenSchema);

43
src/models/nex-token.ts Normal file
View File

@@ -0,0 +1,43 @@
import { Schema, model } from 'mongoose';
import type { Model, HydratedDocument } from 'mongoose';
// TODO - Move these to the types folder, just putting them here to push this out the door
export interface INEXToken {
token: string;
game_server_id: string;
pid: number;
info: {
system_type: number;
token_type: number;
title_id: bigint;
issued: Date;
expires: Date;
};
}
export interface INEXTokenMethods {}
interface INEXTokenQueryHelpers {}
export interface NEXTokenModel extends Model<INEXToken, INEXTokenQueryHelpers, INEXTokenMethods> {}
export type HydratedNEXTokenDocument = HydratedDocument<INEXToken, INEXTokenMethods>;
const NEXTokenSchema = new Schema<INEXToken, NEXTokenModel, INEXTokenMethods>({
token: String,
game_server_id: String,
pid: Number,
info: {
system_type: Number,
token_type: Number,
title_id: BigInt,
issued: Date,
expires: Date
}
});
NEXTokenSchema.index({ 'info.expires': 1 }, { expireAfterSeconds: 0 });
NEXTokenSchema.index({ token: 1 });
NEXTokenSchema.index({ pid: 1 });
export const NEXToken = model<INEXToken, NEXTokenModel>('NEXToken', NEXTokenSchema);

45
src/models/oauth-token.ts Normal file
View File

@@ -0,0 +1,45 @@
import { Schema, model } from 'mongoose';
import type { Model, HydratedDocument } from 'mongoose';
// TODO - Move these to the types folder, just putting them here to push this out the door
export interface IOAuthToken {
token: string;
client_id: string;
client_secret: string;
pid: number;
info: {
system_type: number;
token_type: number;
title_id: bigint;
issued: Date;
expires: Date;
};
}
export interface IOAuthTokenMethods {}
interface IOAuthTokenQueryHelpers {}
export interface OAuthTokenModel extends Model<IOAuthToken, IOAuthTokenQueryHelpers, IOAuthTokenMethods> {}
export type HydratedOAuthTokenDocument = HydratedDocument<IOAuthToken, IOAuthTokenMethods>;
const OAuthTokenSchema = new Schema<IOAuthToken, OAuthTokenModel, IOAuthTokenMethods>({
token: String,
client_id: String,
client_secret: String,
pid: Number,
info: {
system_type: Number,
token_type: Number,
title_id: BigInt,
issued: Date,
expires: Date
}
});
OAuthTokenSchema.index({ 'info.expires': 1 }, { expireAfterSeconds: 0 });
OAuthTokenSchema.index({ token: 1 });
OAuthTokenSchema.index({ pid: 1 });
export const OAuthToken = model<IOAuthToken, OAuthTokenModel>('OAuthToken', OAuthTokenSchema);

View File

@@ -0,0 +1,41 @@
import { Schema, model } from 'mongoose';
import type { Model, HydratedDocument } from 'mongoose';
// TODO - Move these to the types folder, just putting them here to push this out the door
export interface IPasswordResetToken {
token: string;
pid: number;
info: {
system_type: number;
token_type: number;
title_id: bigint;
issued: Date;
expires: Date;
};
}
export interface IPasswordResetTokenMethods {}
interface IPasswordResetTokenQueryHelpers {}
export interface PasswordResetTokenModel extends Model<IPasswordResetToken, IPasswordResetTokenQueryHelpers, IPasswordResetTokenMethods> {}
export type HydratedPasswordResetTokenDocument = HydratedDocument<IPasswordResetToken, IPasswordResetTokenMethods>;
const PasswordResetTokenSchema = new Schema<IPasswordResetToken, PasswordResetTokenModel, IPasswordResetTokenMethods>({
token: String,
pid: Number,
info: {
system_type: Number,
token_type: Number,
title_id: BigInt,
issued: Date,
expires: Date
}
});
PasswordResetTokenSchema.index({ 'info.expires': 1 }, { expireAfterSeconds: 0 });
PasswordResetTokenSchema.index({ token: 1 });
PasswordResetTokenSchema.index({ pid: 1 });
export const PasswordResetToken = model<IPasswordResetToken, PasswordResetTokenModel>('PasswordResetToken', PasswordResetTokenSchema);

View File

@@ -12,6 +12,10 @@ import { DeviceSchema } from '@/models/device';
import { uploadCDNAsset } from '@/util';
import { LOG_ERROR, LOG_WARN } from '@/logger';
import { config } from '@/config-manager';
import { IndependentServiceToken } from '@/models/independent-service-token';
import { NEXToken } from '@/models/nex-token';
import { OAuthToken } from '@/models/oauth-token';
import { PasswordResetToken } from '@/models/password-reset-token';
import type { IPNID, IPNIDMethods, PNIDModel } from '@/types/mongoose/pnid';
import type { PNIDPermissionFlag } from '@/types/common/permission-flags';
@@ -368,6 +372,15 @@ PNIDSchema.method('scrub', async function scrub() {
await this.save();
});
PNIDSchema.method('removeAllTokens', async function removeAllTokens() {
await Promise.all([
IndependentServiceToken.deleteMany({ pid: this.pid }),
NEXToken.deleteMany({ pid: this.pid }),
OAuthToken.deleteMany({ pid: this.pid }),
PasswordResetToken.deleteMany({ pid: this.pid })
]);
});
PNIDSchema.method('hasPermission', function hasPermission(flag: PNIDPermissionFlag): boolean {
return (this.permissions & flag) === flag;
});

View File

@@ -1,10 +1,11 @@
import crypto from 'node:crypto';
import express from 'express';
import bcrypt from 'bcrypt';
import { getPNIDByUsername, getPNIDByAPIRefreshToken } from '@/database';
import { nintendoPasswordHash, generateToken } from '@/util';
import { nintendoPasswordHash } from '@/util';
import { OAuthToken } from '@/models/oauth-token';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
import { config } from '@/config-manager';
import { LOG_ERROR } from '@/logger';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
@@ -112,30 +113,37 @@ router.post('/', async (request: express.Request, response: express.Response): P
return;
}
const accessTokenOptions = {
system_type: SystemType.API,
token_type: TokenType.OAuthAccess,
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const refreshTokenOptions = {
system_type: SystemType.API,
token_type: TokenType.OAuthRefresh,
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + 12 * 3600 * 1000)
};
try {
const accessTokenBuffer = await generateToken(config.aes_key, accessTokenOptions);
const refreshTokenBuffer = await generateToken(config.aes_key, refreshTokenOptions);
const accessToken = crypto.randomBytes(16).toString('hex');
const newRefreshToken = crypto.randomBytes(20).toString('hex');
const accessToken = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
const newRefreshToken = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
await OAuthToken.create({
token: crypto.createHash('sha256').update(accessToken).digest('hex'),
client_id: 'a2efa818a34fa16b8afbc8a74eba3eda', // TODO - This is the Wii U config, change this?
client_secret: 'c91cdb5658bd4954ade78533a339cf9a', // TODO - This is the Wii U config, change this?
pid: pnid.pid,
info: {
system_type: SystemType.API,
token_type: TokenType.OAuthAccess,
title_id: BigInt(0),
issued: new Date(),
expires: new Date(Date.now() + (3600 * 1000))
}
});
await OAuthToken.create({
token: crypto.createHash('sha256').update(newRefreshToken).digest('hex'),
client_id: 'a2efa818a34fa16b8afbc8a74eba3eda', // TODO - This is the Wii U config, change this?
client_secret: 'c91cdb5658bd4954ade78533a339cf9a', // TODO - This is the Wii U config, change this?
pid: pnid.pid,
info: {
system_type: SystemType.API,
token_type: TokenType.OAuthRefresh,
title_id: BigInt(0),
issued: new Date(),
expires: new Date(Date.now() + 12 * 3600 * 1000)
}
});
// TODO - Handle null tokens

View File

@@ -6,12 +6,13 @@ import moment from 'moment';
import hcaptcha from 'hcaptcha';
import Mii from 'mii-js';
import { doesPNIDExist, connection as databaseConnection } from '@/database';
import { isValidBirthday, getAgeFromDate, nintendoPasswordHash, sendConfirmationEmail, generateToken } from '@/util';
import { isValidBirthday, getAgeFromDate, nintendoPasswordHash, sendConfirmationEmail } from '@/util';
import IP2LocationManager from '@/ip2location';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
import { LOG_ERROR } from '@/logger';
import { PNID } from '@/models/pnid';
import { OAuthToken } from '@/models/oauth-token';
import { NEXAccount } from '@/models/nex-account';
import { config, disabledFeatures } from '@/config-manager';
import type { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account';
@@ -435,32 +436,37 @@ router.post('/', async (request: express.Request, response: express.Response): P
await sendConfirmationEmail(pnid);
const accessTokenOptions = {
system_type: SystemType.API,
token_type: TokenType.OAuthAccess,
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const refreshTokenOptions = {
system_type: SystemType.API,
token_type: TokenType.OAuthRefresh,
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + 12 * 3600 * 1000)
};
try {
const accessTokenBuffer = await generateToken(config.aes_key, accessTokenOptions);
const refreshTokenBuffer = await generateToken(config.aes_key, refreshTokenOptions);
const accessToken = crypto.randomBytes(16).toString('hex');
const refreshToken = crypto.randomBytes(20).toString('hex');
const accessToken = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
const refreshToken = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
await OAuthToken.create({
token: crypto.createHash('sha256').update(accessToken).digest('hex'),
client_id: 'a2efa818a34fa16b8afbc8a74eba3eda', // TODO - This is the Wii U config, change this?
client_secret: 'c91cdb5658bd4954ade78533a339cf9a', // TODO - This is the Wii U config, change this?
pid: pnid.pid,
info: {
system_type: SystemType.API,
token_type: TokenType.OAuthAccess,
title_id: BigInt(0),
issued: new Date(),
expires: new Date(Date.now() + (3600 * 1000))
}
});
// TODO - Handle null tokens
await OAuthToken.create({
token: crypto.createHash('sha256').update(refreshToken).digest('hex'),
client_id: 'a2efa818a34fa16b8afbc8a74eba3eda', // TODO - This is the Wii U config, change this?
client_secret: 'c91cdb5658bd4954ade78533a339cf9a', // TODO - This is the Wii U config, change this?
pid: pnid.pid,
info: {
system_type: SystemType.API,
token_type: TokenType.OAuthRefresh,
title_id: BigInt(0),
issued: new Date(),
expires: new Date(Date.now() + 12 * 3600 * 1000)
}
});
response.json({
access_token: accessToken,

View File

@@ -1,8 +1,12 @@
import crypto from 'node:crypto';
import express from 'express';
import bcrypt from 'bcrypt';
import { PNID } from '@/models/pnid';
import { decryptToken, unpackToken, nintendoPasswordHash } from '@/util';
import type { Token } from '@/types/common/token';
import { PasswordResetToken } from '@/models/password-reset-token';
import { nintendoPasswordHash } from '@/util';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
import { getPNIDByPID } from '@/database';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
const router = express.Router();
@@ -27,10 +31,53 @@ router.post('/', async (request: express.Request, response: express.Response): P
return;
}
let unpackedToken: Token;
let pnid: HydratedPNIDDocument | null = null;
try {
const decryptedToken = await decryptToken(Buffer.from(token, 'hex'));
unpackedToken = unpackToken(decryptedToken);
const passwordResetToken = await PasswordResetToken.findOne({
token: crypto.createHash('sha256').update(token).digest('hex')
});
if (!passwordResetToken) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid token'
});
return;
}
if (passwordResetToken.info.system_type !== SystemType.PasswordReset) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid token'
});
return;
}
if (passwordResetToken.info.token_type !== TokenType.PasswordReset) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid token'
});
return;
}
if (passwordResetToken.info.expires < new Date()) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Token expired'
});
return;
}
pnid = await getPNIDByPID(passwordResetToken.pid);
} catch {
response.status(400).json({
app: 'api',
@@ -41,18 +88,6 @@ router.post('/', async (request: express.Request, response: express.Response): P
return;
}
if (unpackedToken.expire_time < Date.now()) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Token expired'
});
return;
}
const pnid = await PNID.findOne({ pid: unpackedToken.pid });
if (!pnid) {
response.status(400).json({
app: 'api',
@@ -138,6 +173,7 @@ router.post('/', async (request: express.Request, response: express.Response): P
pnid.password = passwordHash;
await pnid.removeAllTokens();
await pnid.save();
response.json({

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { CreateAuditLogCommentRequest, CreateAuditLogCommentResponse } from '@pretendonetwork/grpc/account/v2/create_audit_log_comment_rpc';
export async function createAuditLogComment(_request: CreateAuditLogCommentRequest): Promise<CreateAuditLogCommentResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { CreateBanCommentRequest, CreateBanCommentResponse } from '@pretendonetwork/grpc/account/v2/create_ban_comment_rpc';
export async function createBanComment(_request: CreateBanCommentRequest): Promise<CreateBanCommentResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { CreateServerRequest, CreateServerResponse } from '@pretendonetwork/grpc/account/v2/create_server_rpc';
export async function createServer(_request: CreateServerRequest): Promise<CreateServerResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { DeletePNIDRequest, DeletePNIDResponse } from '@pretendonetwork/grpc/account/v2/delete_pnid_rpc';
export async function deletePNID(_request: DeletePNIDRequest): Promise<DeletePNIDResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { DeleteServerRequest, DeleteServerResponse } from '@pretendonetwork/grpc/account/v2/delete_server_rpc';
export async function deleteServer(_request: DeleteServerRequest): Promise<DeleteServerResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,153 @@
import crypto from 'node:crypto';
import { Status, ServerError } from 'nice-grpc';
import { IndependentServiceToken } from '@/models/independent-service-token';
import { Device } from '@/models/device';
import { NEXAccount } from '@/models/nex-account';
import { getPNIDByPID } from '@/database';
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
import { config } from '@/config-manager';
import type { ExchangeIndependentServiceTokenForUserDataRequest, ExchangeIndependentServiceTokenForUserDataResponse } from '@pretendonetwork/grpc/account/v2/exchange_independent_service_token_for_user_data_rpc';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
import type { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account';
import type { HydratedIndependentServiceTokenDocument } from '@/models/independent-service-token';
export async function exchangeIndependentServiceTokenForUserData(request: ExchangeIndependentServiceTokenForUserDataRequest): Promise<ExchangeIndependentServiceTokenForUserDataResponse> {
let pnid: HydratedPNIDDocument | null = null;
let nexAccount: HydratedNEXAccountDocument | null = null;
let serviceToken: HydratedIndependentServiceTokenDocument | null = null;
try {
serviceToken = await IndependentServiceToken.findOne({
token: crypto.createHash('sha256').update(request.token).digest('hex')
});
if (!serviceToken) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
// TODO - Add checks for the client IDs and matching system/token types here
if (serviceToken.info.expires < new Date()) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
pnid = await getPNIDByPID(serviceToken.pid);
nexAccount = await NEXAccount.findOne({ pid: serviceToken.pid });
} catch {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
if (!nexAccount) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token. No user found');
}
// TODO - Change this. This duplication sucks, but I wanna push this out the door
if (pnid) {
const devices = (await Device.find({
linked_pids: pnid.pid
})).map((device) => {
return {
model: device.get('model'), // * ".model" gives the Mongoose model
serial: device.serial,
linkedPids: device.linked_pids,
accessLevel: device.access_level,
serverAccessLevel: device.server_access_level,
deviceId: device.device_id
};
});
return {
pnid: {
deleted: pnid.deleted,
pid: pnid.pid,
username: pnid.username,
accessLevel: pnid.access_level,
serverAccessLevel: pnid.server_access_level,
mii: {
name: pnid.mii.name,
data: pnid.mii.data,
url: `${config.cdn.base_url}/mii/${pnid.pid}/standard.tga`
},
creationDate: pnid.creation_date,
birthdate: pnid.birthdate,
gender: pnid.gender,
country: pnid.country,
language: pnid.language,
emailAddress: pnid.email.address,
tierName: pnid.connections.stripe.tier_name,
permissions: {
bannedAllPermanently: pnid.hasPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_PERMANENTLY),
bannedAllTemporarily: pnid.hasPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_TEMPORARILY),
betaAccess: pnid.hasPermission(PNID_PERMISSION_FLAGS.BETA_ACCESS),
accessAdminPanel: pnid.hasPermission(PNID_PERMISSION_FLAGS.ACCESS_ADMIN_PANEL),
createServerConfigs: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_SERVER_CONFIGS),
modifyServerConfigs: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_SERVER_CONFIGS),
deployServer: pnid.hasPermission(PNID_PERMISSION_FLAGS.DEPLOY_SERVER),
modifyPnids: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_PNIDS),
modifyNexAccounts: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_NEX_ACCOUNTS),
modifyConsoles: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_CONSOLES),
banPnids: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_PNIDS),
banNexAccounts: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_NEX_ACCOUNTS),
banConsoles: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_CONSOLES),
moderateMiiverse: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODERATE_MIIVERSE),
createApiKeys: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_API_KEYS),
createBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_BOSS_TASKS),
updateBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_TASKS),
deleteBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_TASKS),
uploadBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPLOAD_BOSS_FILES),
updateBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_FILES),
deleteBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_FILES),
updatePnidPermissions: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_PNID_PERMISSIONS)
},
linkedDevices: devices
},
nexAccount: {
pid: nexAccount.pid,
owningPid: nexAccount.owning_pid,
accessLevel: nexAccount.access_level,
serverAccessLevel: nexAccount.server_access_level,
friendCode: nexAccount.friend_code,
deviceType: nexAccount.device_type
},
tokenInfo: {
systemType: serviceToken.info.system_type as any, // TODO - Stop the any usage
tokenType: serviceToken.info.token_type as any, // TODO - Stop the any usage
pid: BigInt(pnid.pid),
accessLevel: pnid.access_level,
titleId: serviceToken.info.title_id,
issueTime: serviceToken.info.issued,
expireTime: serviceToken.info.expires
},
basicUserInfo: {
// TODO - ban: {}
accessBetaServers: pnid.access_level === 1 || pnid.access_level === 2 || pnid.access_level === 3, // TODO - Remove with a better permission check later
accessDeveloperServers: pnid.access_level === 3 // TODO - Remove with a better permission check later
}
};
} else {
return {
pnid: undefined, // * Throws an error if this is not explictly set?
nexAccount: {
pid: nexAccount.pid,
owningPid: nexAccount.owning_pid,
accessLevel: nexAccount.access_level,
serverAccessLevel: nexAccount.server_access_level,
friendCode: nexAccount.friend_code,
deviceType: nexAccount.device_type
},
tokenInfo: {
systemType: serviceToken.info.system_type as any, // TODO - Stop the any usage
tokenType: serviceToken.info.token_type as any, // TODO - Stop the any usage
pid: BigInt(nexAccount.pid),
accessLevel: nexAccount.access_level,
titleId: serviceToken.info.title_id,
issueTime: serviceToken.info.issued,
expireTime: serviceToken.info.expires
},
basicUserInfo: {
// TODO - ban: {}
accessBetaServers: nexAccount.access_level === 1 || nexAccount.access_level === 2 || nexAccount.access_level === 3, // TODO - Remove with a better permission check later
accessDeveloperServers: nexAccount.access_level === 3 // TODO - Remove with a better permission check later
}
};
}
}

View File

@@ -0,0 +1,90 @@
import crypto from 'node:crypto';
import { Status, ServerError } from 'nice-grpc';
import { NEXToken } from '@/models/nex-token';
import { NEXAccount } from '@/models/nex-account';
import { getPNIDByPID } from '@/database';
import type { ExchangeNEXTokenForUserDataRequest, ExchangeNEXTokenForUserDataResponse } from '@pretendonetwork/grpc/account/v2/exchange_nex_token_for_user_data_rpc';
import type { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account';
import type { HydratedNEXTokenDocument } from '@/models/nex-token';
export async function exchangeNEXTokenForUserData(request: ExchangeNEXTokenForUserDataRequest): Promise<ExchangeNEXTokenForUserDataResponse> {
let nexAccount: HydratedNEXAccountDocument | null = null;
let nexToken: HydratedNEXTokenDocument | null = null;
try {
nexToken = await NEXToken.findOne({
token: crypto.createHash('sha256').update(request.token).digest('hex')
});
if (!nexToken) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
// TODO - Add checks for the game server ID and matching system/token types here
if (nexToken.info.expires < new Date()) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
nexAccount = await NEXAccount.findOne({ pid: nexToken.pid });
} catch {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
if (!nexAccount) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token. No user found');
}
const pnid = await getPNIDByPID(nexAccount.owning_pid || nexAccount.pid);
if (pnid) {
return {
nexAccount: {
pid: nexAccount.pid,
owningPid: nexAccount.owning_pid,
accessLevel: nexAccount.access_level,
serverAccessLevel: nexAccount.server_access_level,
friendCode: nexAccount.friend_code,
deviceType: nexAccount.device_type
},
tokenInfo: {
systemType: nexToken.info.system_type as any, // TODO - Stop the any usage
tokenType: nexToken.info.token_type as any, // TODO - Stop the any usage
pid: BigInt(nexAccount.pid),
accessLevel: nexAccount.access_level,
titleId: nexToken.info.title_id,
issueTime: nexToken.info.issued,
expireTime: nexToken.info.expires
},
basicUserInfo: {
// TODO - ban: {}
accessBetaServers: pnid.access_level === 1 || pnid.access_level === 2 || pnid.access_level === 3, // TODO - Remove with a better permission check later
accessDeveloperServers: pnid.access_level === 3 // TODO - Remove with a better permission check later
}
};
} else {
return {
nexAccount: {
pid: nexAccount.pid,
owningPid: nexAccount.owning_pid,
accessLevel: nexAccount.access_level,
serverAccessLevel: nexAccount.server_access_level,
friendCode: nexAccount.friend_code,
deviceType: nexAccount.device_type
},
tokenInfo: {
systemType: nexToken.info.system_type as any, // TODO - Stop the any usage
tokenType: nexToken.info.token_type as any, // TODO - Stop the any usage
pid: BigInt(nexAccount.pid),
accessLevel: nexAccount.access_level,
titleId: nexToken.info.title_id,
issueTime: nexToken.info.issued,
expireTime: nexToken.info.expires
},
basicUserInfo: {
// TODO - ban: {}
accessBetaServers: nexAccount.access_level === 1 || nexAccount.access_level === 2 || nexAccount.access_level === 3, // TODO - Remove with a better permission check later
accessDeveloperServers: nexAccount.access_level === 3 // TODO - Remove with a better permission check later
}
};
}
}

View File

@@ -0,0 +1,124 @@
import crypto from 'node:crypto';
import { Status, ServerError } from 'nice-grpc';
import { OAuthToken } from '@/models/oauth-token';
import { Device } from '@/models/device';
import { NEXAccount } from '@/models/nex-account';
import { getPNIDByPID } from '@/database';
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
import { config } from '@/config-manager';
import type { ExchangeOAuthTokenForUserDataRequest, ExchangeOAuthTokenForUserDataResponse } from '@pretendonetwork/grpc/account/v2/exchange_oauth_token_for_user_data_rpc';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
import type { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account';
import type { HydratedOAuthTokenDocument } from '@/models/oauth-token';
export async function exchangeOAuthTokenForUserData(request: ExchangeOAuthTokenForUserDataRequest): Promise<ExchangeOAuthTokenForUserDataResponse> {
let pnid: HydratedPNIDDocument | null = null;
let nexAccount: HydratedNEXAccountDocument | null = null;
let oAuthToken: HydratedOAuthTokenDocument | null = null;
try {
oAuthToken = await OAuthToken.findOne({
token: crypto.createHash('sha256').update(request.token).digest('hex')
});
if (!oAuthToken) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
// TODO - Add checks for the client ID/title ID and matching system/token types here
if (oAuthToken.info.expires < new Date()) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
pnid = await getPNIDByPID(oAuthToken.pid);
nexAccount = await NEXAccount.findOne({ pid: oAuthToken.pid });
} catch {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
if (!nexAccount || !pnid) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token. No user found');
}
const devices = (await Device.find({
linked_pids: pnid.pid
})).map((device) => {
return {
model: device.get('model'), // * ".model" gives the Mongoose model
serial: device.serial,
linkedPids: device.linked_pids,
accessLevel: device.access_level,
serverAccessLevel: device.server_access_level,
deviceId: device.device_id
};
});
return {
pnid: {
deleted: pnid.deleted,
pid: pnid.pid,
username: pnid.username,
accessLevel: pnid.access_level,
serverAccessLevel: pnid.server_access_level,
mii: {
name: pnid.mii.name,
data: pnid.mii.data,
url: `${config.cdn.base_url}/mii/${pnid.pid}/standard.tga`
},
creationDate: pnid.creation_date,
birthdate: pnid.birthdate,
gender: pnid.gender,
country: pnid.country,
language: pnid.language,
emailAddress: pnid.email.address,
tierName: pnid.connections.stripe.tier_name,
permissions: {
bannedAllPermanently: pnid.hasPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_PERMANENTLY),
bannedAllTemporarily: pnid.hasPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_TEMPORARILY),
betaAccess: pnid.hasPermission(PNID_PERMISSION_FLAGS.BETA_ACCESS),
accessAdminPanel: pnid.hasPermission(PNID_PERMISSION_FLAGS.ACCESS_ADMIN_PANEL),
createServerConfigs: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_SERVER_CONFIGS),
modifyServerConfigs: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_SERVER_CONFIGS),
deployServer: pnid.hasPermission(PNID_PERMISSION_FLAGS.DEPLOY_SERVER),
modifyPnids: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_PNIDS),
modifyNexAccounts: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_NEX_ACCOUNTS),
modifyConsoles: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_CONSOLES),
banPnids: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_PNIDS),
banNexAccounts: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_NEX_ACCOUNTS),
banConsoles: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_CONSOLES),
moderateMiiverse: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODERATE_MIIVERSE),
createApiKeys: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_API_KEYS),
createBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_BOSS_TASKS),
updateBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_TASKS),
deleteBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_TASKS),
uploadBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPLOAD_BOSS_FILES),
updateBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_FILES),
deleteBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_FILES),
updatePnidPermissions: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_PNID_PERMISSIONS)
},
linkedDevices: devices
},
nexAccount: {
pid: nexAccount.pid,
owningPid: nexAccount.owning_pid,
accessLevel: nexAccount.access_level,
serverAccessLevel: nexAccount.server_access_level,
friendCode: nexAccount.friend_code,
deviceType: nexAccount.device_type
},
tokenInfo: {
systemType: oAuthToken.info.system_type as any, // TODO - Stop the any usage
tokenType: oAuthToken.info.token_type as any, // TODO - Stop the any usage
pid: BigInt(pnid.pid),
accessLevel: pnid.access_level,
titleId: oAuthToken.info.title_id,
issueTime: oAuthToken.info.issued,
expireTime: oAuthToken.info.expires
},
basicUserInfo: {
// TODO - ban: {}
accessBetaServers: pnid.access_level === 1 || pnid.access_level === 2 || pnid.access_level === 3, // TODO - Remove with a better permission check later
accessDeveloperServers: pnid.access_level === 3 // TODO - Remove with a better permission check later
}
};
}

View File

@@ -0,0 +1,120 @@
import crypto from 'node:crypto';
import { Status, ServerError } from 'nice-grpc';
import { PasswordResetToken } from '@/models/password-reset-token';
import { Device } from '@/models/device';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
import { getPNIDByPID } from '@/database';
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
import { config } from '@/config-manager';
import type { ExchangePasswordResetTokenForUserDataRequest, ExchangePasswordResetTokenForUserDataResponse } from '@pretendonetwork/grpc/account/v2/exchange_password_reset_token_for_user_data_rpc';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
import type { HydratedPasswordResetTokenDocument } from '@/models/password-reset-token';
export async function exchangePasswordResetTokenForUserData(request: ExchangePasswordResetTokenForUserDataRequest): Promise<ExchangePasswordResetTokenForUserDataResponse> {
let pnid: HydratedPNIDDocument | null = null;
let passwordResetToken: HydratedPasswordResetTokenDocument | null = null;
try {
passwordResetToken = await PasswordResetToken.findOne({
token: crypto.createHash('sha256').update(request.token).digest('hex')
});
if (!passwordResetToken) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
if (passwordResetToken.info.system_type !== SystemType.PasswordReset) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
if (passwordResetToken.info.token_type !== TokenType.PasswordReset) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
if (passwordResetToken.info.expires < new Date()) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
pnid = await getPNIDByPID(passwordResetToken.pid);
} catch {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
if (!pnid) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token. No user found');
}
const devices = (await Device.find({
linked_pids: pnid.pid
})).map((device) => {
return {
model: device.get('model'), // * ".model" gives the Mongoose model
serial: device.serial,
linkedPids: device.linked_pids,
accessLevel: device.access_level,
serverAccessLevel: device.server_access_level,
deviceId: device.device_id
};
});
return {
pnid: {
deleted: pnid.deleted,
pid: pnid.pid,
username: pnid.username,
accessLevel: pnid.access_level,
serverAccessLevel: pnid.server_access_level,
mii: {
name: pnid.mii.name,
data: pnid.mii.data,
url: `${config.cdn.base_url}/mii/${pnid.pid}/standard.tga`
},
creationDate: pnid.creation_date,
birthdate: pnid.birthdate,
gender: pnid.gender,
country: pnid.country,
language: pnid.language,
emailAddress: pnid.email.address,
tierName: pnid.connections.stripe.tier_name,
permissions: {
bannedAllPermanently: pnid.hasPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_PERMANENTLY),
bannedAllTemporarily: pnid.hasPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_TEMPORARILY),
betaAccess: pnid.hasPermission(PNID_PERMISSION_FLAGS.BETA_ACCESS),
accessAdminPanel: pnid.hasPermission(PNID_PERMISSION_FLAGS.ACCESS_ADMIN_PANEL),
createServerConfigs: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_SERVER_CONFIGS),
modifyServerConfigs: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_SERVER_CONFIGS),
deployServer: pnid.hasPermission(PNID_PERMISSION_FLAGS.DEPLOY_SERVER),
modifyPnids: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_PNIDS),
modifyNexAccounts: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_NEX_ACCOUNTS),
modifyConsoles: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_CONSOLES),
banPnids: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_PNIDS),
banNexAccounts: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_NEX_ACCOUNTS),
banConsoles: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_CONSOLES),
moderateMiiverse: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODERATE_MIIVERSE),
createApiKeys: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_API_KEYS),
createBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_BOSS_TASKS),
updateBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_TASKS),
deleteBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_TASKS),
uploadBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPLOAD_BOSS_FILES),
updateBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_FILES),
deleteBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_FILES),
updatePnidPermissions: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_PNID_PERMISSIONS)
},
linkedDevices: devices
},
tokenInfo: {
systemType: passwordResetToken.info.system_type as any, // TODO - Stop the any usage
tokenType: passwordResetToken.info.token_type as any, // TODO - Stop the any usage
pid: BigInt(pnid.pid),
accessLevel: pnid.access_level,
titleId: passwordResetToken.info.title_id,
issueTime: passwordResetToken.info.issued,
expireTime: passwordResetToken.info.expires
},
basicUserInfo: {
// TODO - ban: {}
accessBetaServers: pnid.access_level === 1 || pnid.access_level === 2 || pnid.access_level === 3, // TODO - Remove with a better permission check later
accessDeveloperServers: pnid.access_level === 3 // TODO - Remove with a better permission check later
}
};
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { GetBanRequest, GetBanResponse } from '@pretendonetwork/grpc/account/v2/get_ban_rpc';
export async function getBan(_request: GetBanRequest): Promise<GetBanResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { GetDeviceRequest, GetDeviceResponse } from '@pretendonetwork/grpc/account/v2/get_device_rpc';
export async function getDevice(_request: GetDeviceRequest): Promise<GetDeviceResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { GetNEXAccountRequest, GetNEXAccountResponse } from '@pretendonetwork/grpc/account/v2/get_nex_account_rpc';
export async function getNEXAccount(_request: GetNEXAccountRequest): Promise<GetNEXAccountResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { GetPNIDRequest, GetPNIDResponse } from '@pretendonetwork/grpc/account/v2/get_pnid_rpc';
export async function getPNID(_request: GetPNIDRequest): Promise<GetPNIDResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { GetServerRequest, GetServerResponse } from '@pretendonetwork/grpc/account/v2/get_server_rpc';
export async function getServer(_request: GetServerRequest): Promise<GetServerResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -1,56 +1,78 @@
import { ServerError, Status } from 'nice-grpc';
import { getUserData } from '@/services/grpc/account/v2/get-user-data';
import { getNEXPassword } from '@/services/grpc/account/v2/get-nex-password';
import { getNEXData } from '@/services/grpc/account/v2/get-nex-data';
import { updatePNIDPermissions } from '@/services/grpc/account/v2/update-pnid-permissions';
import { exchangeTokenForUserData } from '@/services/grpc/account/v2/exchange-token-for-user-data';
import { exchangeOAuthTokenForUserData } from '@/services/grpc/account/v2/exchange-oauth-token-for-user-data';
import { exchangeNEXTokenForUserData } from '@/services/grpc/account/v2/exchange-nex-token-for-user-data';
import { exchangeIndependentServiceTokenForUserData } from '@/services/grpc/account/v2/exchange-independent-service-token-for-user-data';
import { exchangePasswordResetTokenForUserData } from '@/services/grpc/account/v2/exchange-password-reset-token-for-user-data';
import { validateIndependentServiceToken } from '@/services/grpc/account/v2/validate-independent-service-token';
import { deleteAccount } from '@/services/grpc/account/v2/delete-account';
import { listDevices } from '@/services/grpc/account/v2/list-devices';
import { getDevice } from '@/services/grpc/account/v2/get-device';
import { updateDevice } from '@/services/grpc/account/v2/update-device';
import { listNEXAccounts } from '@/services/grpc/account/v2/list-nex-accounts';
import { getNEXAccount } from '@/services/grpc/account/v2/get-nex-account';
import { updateNEXAccount } from '@/services/grpc/account/v2/update-nex-account';
import { listServers } from '@/services/grpc/account/v2/list-servers';
import { createServer } from '@/services/grpc/account/v2/create-server';
import { getServer } from '@/services/grpc/account/v2/get-server';
import { updateServer } from '@/services/grpc/account/v2/update-server';
import { deleteServer } from '@/services/grpc/account/v2/delete-server';
import { listPNIDs } from '@/services/grpc/account/v2/list-pnids';
import { getPNID } from '@/services/grpc/account/v2/get-pnid';
import { getPNIDs } from '@/services/grpc/account/v2/get-pnids';
import type { ServiceImplementation } from 'nice-grpc';
import type { AccountServiceDefinition } from '@pretendonetwork/grpc/account/v2/account_service';
import { updatePNID } from '@/services/grpc/account/v2/update-pnid';
import { deletePNID } from '@/services/grpc/account/v2/delete-pnid';
import { listAuditLogs } from '@/services/grpc/account/v2/list-audit-logs';
import { listAuditLogComments } from '@/services/grpc/account/v2/list-audit-log-comments';
import { createAuditLogComment } from '@/services/grpc/account/v2/create-audit-log-comment';
import { listBans } from '@/services/grpc/account/v2/list-bans';
import { issueBan } from '@/services/grpc/account/v2/issue-ban';
import { getBan } from '@/services/grpc/account/v2/get-ban';
import { updateBan } from '@/services/grpc/account/v2/update-ban';
import { pardonBan } from '@/services/grpc/account/v2/pardon-ban';
import { listBanComments } from '@/services/grpc/account/v2/list-ban-comments';
import { createBanComment } from '@/services/grpc/account/v2/create-ban-comment';
import type { AccountServiceImplementation } from '@pretendonetwork/grpc/account/v2/account_service';
const notImplemented = (): never => {
throw new ServerError(Status.UNIMPLEMENTED, 'Not implemented');
};
export const accountServiceImplementationV2: ServiceImplementation<AccountServiceDefinition> = {
export const accountServiceImplementationV2: AccountServiceImplementation = {
getUserData,
getNEXPassword,
getNEXData,
updatePNIDPermissions,
exchangeTokenForUserData,
exchangeOAuthTokenForUserData,
exchangeNEXTokenForUserData,
exchangeIndependentServiceTokenForUserData,
exchangePasswordResetTokenForUserData,
validateIndependentServiceToken,
deleteAccount,
listDevices,
getDevice,
updateDevice,
listNEXAccounts,
getNEXAccount,
updateNEXAccount,
listServers,
createServer,
getServer,
updateServer,
deleteServer,
listPNIDs,
getPNID,
getPNIDs,
// The following methods are not yet implemented
createAuditLogComment: notImplemented,
createBanComment: notImplemented,
createServer: notImplemented,
deleteServer: notImplemented,
deletePNID: notImplemented,
exchangeIndependentServiceTokenForUserData: notImplemented,
exchangeNEXTokenForUserData: notImplemented,
exchangeOAuthTokenForUserData: notImplemented,
exchangePasswordResetTokenForUserData: notImplemented,
getBan: notImplemented,
getDevice: notImplemented,
getNEXAccount: notImplemented,
getPNID: notImplemented,
getServer: notImplemented,
issueBan: notImplemented,
listAuditLogComments: notImplemented,
listBans: notImplemented,
listDevices: notImplemented,
listNEXAccounts: notImplemented,
listPNIDs: notImplemented,
listServers: notImplemented,
listAuditLogs: notImplemented,
listBanComments: notImplemented,
pardonBan: notImplemented,
updateBan: notImplemented,
updateDevice: notImplemented,
updateNEXAccount: notImplemented,
updateServer: notImplemented,
updatePNID: notImplemented,
validateIndependentServiceToken: notImplemented
updatePNID,
deletePNID,
listAuditLogs,
listAuditLogComments,
createAuditLogComment,
listBans,
issueBan,
getBan,
updateBan,
pardonBan,
listBanComments,
createBanComment
};

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { IssueBanRequest, IssueBanResponse } from '@pretendonetwork/grpc/account/v2/issue_ban_rpc';
export async function issueBan(_request: IssueBanRequest): Promise<IssueBanResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { ListAuditLogCommentsRequest, ListAuditLogCommentsResponse } from '@pretendonetwork/grpc/account/v2/list_audit_log_comments_rpc';
export async function listAuditLogComments(_request: ListAuditLogCommentsRequest): Promise<ListAuditLogCommentsResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { ListAuditLogsRequest, ListAuditLogsResponse } from '@pretendonetwork/grpc/account/v2/list_audit_logs_rpc';
export async function listAuditLogs(_request: ListAuditLogsRequest): Promise<ListAuditLogsResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { ListBanCommentsRequest, ListBanCommentsResponse } from '@pretendonetwork/grpc/account/v2/list_ban_comments_rpc';
export async function listBanComments(_request: ListBanCommentsRequest): Promise<ListBanCommentsResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { ListBansRequest, ListBansResponse } from '@pretendonetwork/grpc/account/v2/list_bans_rpc';
export async function listBans(_request: ListBansRequest): Promise<ListBansResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { ListDevicesRequest, ListDevicesResponse } from '@pretendonetwork/grpc/account/v2/list_devices_rpc';
export async function listDevices(_request: ListDevicesRequest): Promise<ListDevicesResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { ListNEXAccountsRequest, ListNEXAccountsResponse } from '@pretendonetwork/grpc/account/v2/list_nex_accounts_rpc';
export async function listNEXAccounts(_request: ListNEXAccountsRequest): Promise<ListNEXAccountsResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { ListPNIDsRequest, ListPNIDsResponse } from '@pretendonetwork/grpc/account/v2/list_pnids_rpc';
export async function listPNIDs(_request: ListPNIDsRequest): Promise<ListPNIDsResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { ListServersRequest, ListServersResponse } from '@pretendonetwork/grpc/account/v2/list_servers_rpc';
export async function listServers(_request: ListServersRequest): Promise<ListServersResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { PardonBanRequest, PardonBanResponse } from '@pretendonetwork/grpc/account/v2/pardon_ban_rpc';
export async function pardonBan(_request: PardonBanRequest): Promise<PardonBanResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { UpdateBanRequest, UpdateBanResponse } from '@pretendonetwork/grpc/account/v2/update_ban_rpc';
export async function updateBan(_request: UpdateBanRequest): Promise<UpdateBanResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { UpdateDeviceRequest, UpdateDeviceResponse } from '@pretendonetwork/grpc/account/v2/update_device_rpc';
export async function updateDevice(_request: UpdateDeviceRequest): Promise<UpdateDeviceResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { UpdateNEXAccountRequest, UpdateNEXAccountResponse } from '@pretendonetwork/grpc/account/v2/update_nex_account_rpc';
export async function updateNEXAccount(_request: UpdateNEXAccountRequest): Promise<UpdateNEXAccountResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { UpdatePNIDRequest, UpdatePNIDResponse } from '@pretendonetwork/grpc/account/v2/update_pnid_rpc';
export async function updatePNID(_request: UpdatePNIDRequest): Promise<UpdatePNIDResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,9 @@
import { Status, ServerError } from 'nice-grpc';
import type { UpdateServerRequest, UpdateServerResponse } from '@pretendonetwork/grpc/account/v2/update_server_rpc';
export async function updateServer(_request: UpdateServerRequest): Promise<UpdateServerResponse> {
throw new ServerError(
Status.UNIMPLEMENTED,
'Stubbed'
);
}

View File

@@ -0,0 +1,86 @@
import crypto from 'node:crypto';
import { Status, ServerError } from 'nice-grpc';
import { Server } from '@/models/server';
import { PNID } from '@/models/pnid';
import { NEXAccount } from '@/models/nex-account';
import type { ValidateIndependentServiceTokenRequest, ValidateIndependentServiceTokenResponse } from '@pretendonetwork/grpc/account/v2/validate_independent_service_token_rpc';
import type { HydratedServerDocument } from '@/types/mongoose/server';
export async function validateIndependentServiceToken(request: ValidateIndependentServiceTokenRequest): Promise<ValidateIndependentServiceTokenResponse> {
if (request.clientIds.length === 0) {
throw new ServerError(Status.INVALID_ARGUMENT, 'No server identification data sent');
}
let server: HydratedServerDocument | null = null;
try {
server = await Server.findOne({
client_id: {
$in: request.clientIds
}
});
} catch {
return {
isValid: false
};
}
if (!server) {
return {
isValid: false
};
}
const token = Buffer.from(request.token, 'hex');
if (token.length !== 60) {
return {
isValid: false
};
}
const tokenBody = token.subarray(0, 28);
const expectedHMAC = token.subarray(28);
const calculatedHMAC = crypto.createHmac('sha256', server.aes_key).update(tokenBody).digest();
if (!crypto.timingSafeEqual(expectedHMAC, calculatedHMAC)) {
return {
isValid: false
};
}
// TODO - Add ban lookups once bans are no longer stubbed
const pid = tokenBody.readUInt32BE(0);
const nexAccount = await NEXAccount.findOne({
pid: pid
});
if (!nexAccount) {
return {
isValid: false
};
}
const pnid = await PNID.findOne({
pid: pid
});
if (pnid) {
return {
isValid: true,
basicUserInfo: {
// TODO - ban: {}
accessBetaServers: pnid.access_level === 1 || pnid.access_level === 2 || pnid.access_level === 3, // TODO - Remove with a better permission check later
accessDeveloperServers: pnid.access_level === 3 // TODO - Remove with a better permission check later
}
};
} else {
return {
isValid: true,
basicUserInfo: {
accessBetaServers: nexAccount.access_level === 1 || nexAccount.access_level === 2 || nexAccount.access_level === 3, // TODO - Remove with a better permission check later
accessDeveloperServers: nexAccount.access_level === 3 // TODO - Remove with a better permission check later
}
};
}
}

View File

@@ -1,10 +1,11 @@
import crypto from 'node:crypto';
import { Status, ServerError } from 'nice-grpc';
import bcrypt from 'bcrypt';
import { getPNIDByUsername, getPNIDByAPIRefreshToken } from '@/database';
import { nintendoPasswordHash, generateToken } from '@/util';
import { nintendoPasswordHash } from '@/util';
import { OAuthToken } from '@/models/oauth-token';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
import { config } from '@/config-manager';
import type { LoginRequest, LoginResponse, DeepPartial } from '@pretendonetwork/grpc/api/login_rpc';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
@@ -56,31 +57,36 @@ export async function login(request: LoginRequest): Promise<DeepPartial<LoginRes
throw new ServerError(Status.UNAUTHENTICATED, 'Account has been deleted');
}
const accessTokenOptions = {
system_type: SystemType.API,
token_type: TokenType.OAuthAccess,
const accessToken = crypto.randomBytes(16).toString('hex');
const newRefreshToken = crypto.randomBytes(20).toString('hex');
await OAuthToken.create({
token: crypto.createHash('sha256').update(accessToken).digest('hex'),
client_id: 'a2efa818a34fa16b8afbc8a74eba3eda', // TODO - This is the Wii U config, change this?
client_secret: 'c91cdb5658bd4954ade78533a339cf9a', // TODO - This is the Wii U config, change this?
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
info: {
system_type: SystemType.API,
token_type: TokenType.OAuthAccess,
title_id: BigInt(0),
issued: new Date(),
expires: new Date(Date.now() + (3600 * 1000))
}
});
const refreshTokenOptions = {
system_type: SystemType.API,
token_type: TokenType.OAuthRefresh,
await OAuthToken.create({
token: crypto.createHash('sha256').update(newRefreshToken).digest('hex'),
client_id: 'a2efa818a34fa16b8afbc8a74eba3eda', // TODO - This is the Wii U config, change this?
client_secret: 'c91cdb5658bd4954ade78533a339cf9a', // TODO - This is the Wii U config, change this?
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + 12 * 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
info: {
system_type: SystemType.API,
token_type: TokenType.OAuthRefresh,
title_id: BigInt(0),
issued: new Date(),
expires: new Date(Date.now() + 12 * 3600 * 1000)
}
});
return {
accessToken: accessToken,

View File

@@ -6,10 +6,11 @@ import moment from 'moment';
import hcaptcha from 'hcaptcha';
import Mii from 'mii-js';
import { doesPNIDExist, connection as databaseConnection } from '@/database';
import { nintendoPasswordHash, sendConfirmationEmail, generateToken } from '@/util';
import { nintendoPasswordHash, sendConfirmationEmail } from '@/util';
import { LOG_ERROR } from '@/logger';
import { PNID } from '@/models/pnid';
import { NEXAccount } from '@/models/nex-account';
import { OAuthToken } from '@/models/oauth-token';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
import { config, disabledFeatures } from '@/config-manager';
@@ -231,31 +232,36 @@ export async function register(request: RegisterRequest): Promise<DeepPartial<Lo
await sendConfirmationEmail(pnid);
const accessTokenOptions = {
system_type: SystemType.API,
token_type: TokenType.OAuthAccess,
const accessToken = crypto.randomBytes(16).toString('hex');
const refreshToken = crypto.randomBytes(20).toString('hex');
await OAuthToken.create({
token: crypto.createHash('sha256').update(accessToken).digest('hex'),
client_id: 'a2efa818a34fa16b8afbc8a74eba3eda', // TODO - This is the Wii U config, change this?
client_secret: 'c91cdb5658bd4954ade78533a339cf9a', // TODO - This is the Wii U config, change this?
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
info: {
system_type: SystemType.API,
token_type: TokenType.OAuthAccess,
title_id: BigInt(0),
issued: new Date(),
expires: new Date(Date.now() + (3600 * 1000))
}
});
const refreshTokenOptions = {
system_type: SystemType.API,
token_type: TokenType.OAuthRefresh,
await OAuthToken.create({
token: crypto.createHash('sha256').update(refreshToken).digest('hex'),
client_id: 'a2efa818a34fa16b8afbc8a74eba3eda', // TODO - This is the Wii U config, change this?
client_secret: 'c91cdb5658bd4954ade78533a339cf9a', // TODO - This is the Wii U config, change this?
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + 12 * 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
info: {
system_type: SystemType.API,
token_type: TokenType.OAuthRefresh,
title_id: BigInt(0),
issued: new Date(),
expires: new Date(Date.now() + 12 * 3600 * 1000)
}
});
return {
accessToken: accessToken,

View File

@@ -1,10 +1,14 @@
import crypto from 'node:crypto';
import bcrypt from 'bcrypt';
import { Status, ServerError } from 'nice-grpc';
import { decryptToken, unpackToken, nintendoPasswordHash } from '@/util';
import { PasswordResetToken } from '@/models/password-reset-token';
import { nintendoPasswordHash } from '@/util';
import { getPNIDByPID } from '@/database';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
import type { ResetPasswordRequest } from '@pretendonetwork/grpc/api/reset_password_rpc';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
import type { Token } from '@/types/common/token';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
// * This sucks
const PASSWORD_WORD_OR_NUMBER_REGEX = /(?=.*[a-zA-Z])(?=.*\d).*/;
@@ -21,20 +25,33 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Empt
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing token');
}
let unpackedToken: Token;
let pnid: HydratedPNIDDocument | null = null;
try {
const decryptedToken = await decryptToken(Buffer.from(token, 'base64'));
unpackedToken = unpackToken(decryptedToken);
const passwordResetToken = await PasswordResetToken.findOne({
token: crypto.createHash('sha256').update(token).digest('hex')
});
if (!passwordResetToken) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
if (passwordResetToken.info.system_type !== SystemType.PasswordReset) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
if (passwordResetToken.info.token_type !== TokenType.PasswordReset) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
if (passwordResetToken.info.expires < new Date()) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
pnid = await getPNIDByPID(passwordResetToken.pid);
} catch {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
if (unpackedToken.expire_time < Date.now()) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Token expired');
}
const pnid = await getPNIDByPID(unpackedToken.pid);
if (!pnid) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token. No user found');
}
@@ -72,6 +89,7 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Empt
pnid.password = passwordHash;
await pnid.removeAllTokens();
await pnid.save();
return {};

View File

@@ -1,8 +1,9 @@
import crypto from 'node:crypto';
import { Status, ServerError } from 'nice-grpc';
import bcrypt from 'bcrypt';
import { getPNIDByUsername, getPNIDByAPIRefreshToken } from '@/database';
import { nintendoPasswordHash, generateToken } from '@/util';
import { config } from '@/config-manager';
import { nintendoPasswordHash } from '@/util';
import { OAuthToken } from '@/models/oauth-token';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
import type { LoginRequest, LoginResponse, DeepPartial } from '@pretendonetwork/grpc/api/v2/login_rpc';
@@ -56,39 +57,41 @@ export async function login(request: LoginRequest): Promise<DeepPartial<LoginRes
throw new ServerError(Status.UNAUTHENTICATED, 'Account has been deleted');
}
const accessTokenOptions = {
system_type: SystemType.API,
token_type: TokenType.OAuthAccess,
const accessToken = crypto.randomBytes(16).toString('hex');
const newRefreshToken = crypto.randomBytes(20).toString('hex');
await OAuthToken.create({
token: crypto.createHash('sha256').update(accessToken).digest('hex'),
client_id: 'a2efa818a34fa16b8afbc8a74eba3eda', // TODO - This is the Wii U config, change this?
client_secret: 'c91cdb5658bd4954ade78533a339cf9a', // TODO - This is the Wii U config, change this?
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
info: {
system_type: SystemType.API,
token_type: TokenType.OAuthAccess,
title_id: BigInt(0),
issued: new Date(),
expires: new Date(Date.now() + (3600 * 1000))
}
});
const refreshTokenOptions = {
system_type: SystemType.API,
token_type: TokenType.OAuthAccess,
await OAuthToken.create({
token: crypto.createHash('sha256').update(newRefreshToken).digest('hex'),
client_id: 'a2efa818a34fa16b8afbc8a74eba3eda', // TODO - This is the Wii U config, change this?
client_secret: 'c91cdb5658bd4954ade78533a339cf9a', // TODO - This is the Wii U config, change this?
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + 12 * 3600 * 1000)
};
const accessTokenBuffer = await generateToken(config.aes_key, accessTokenOptions);
const refreshTokenBuffer = await generateToken(config.aes_key, refreshTokenOptions);
if (!accessTokenBuffer) {
throw new ServerError(Status.INTERNAL, 'Failed to generate access token');
}
if (!refreshTokenBuffer) {
throw new ServerError(Status.INTERNAL, 'Failed to generate refresh token');
}
info: {
system_type: SystemType.API,
token_type: TokenType.OAuthRefresh,
title_id: BigInt(0),
issued: new Date(),
expires: new Date(Date.now() + 12 * 3600 * 1000)
}
});
return {
accessToken: accessTokenBuffer.toString('hex'),
accessToken: accessToken,
tokenType: 'Bearer',
expiresIn: 3600,
refreshToken: refreshTokenBuffer.toString('hex')
refreshToken: newRefreshToken
};
}

View File

@@ -6,10 +6,11 @@ import moment from 'moment';
import hcaptcha from 'hcaptcha';
import Mii from 'mii-js';
import { doesPNIDExist, connection as databaseConnection } from '@/database';
import { nintendoPasswordHash, sendConfirmationEmail, generateToken } from '@/util';
import { nintendoPasswordHash, sendConfirmationEmail } from '@/util';
import { LOG_ERROR } from '@/logger';
import { PNID } from '@/models/pnid';
import { NEXAccount } from '@/models/nex-account';
import { OAuthToken } from '@/models/oauth-token';
import { config, disabledFeatures } from '@/config-manager';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
@@ -231,39 +232,41 @@ export async function register(request: RegisterRequest): Promise<DeepPartial<Lo
await sendConfirmationEmail(pnid);
const accessTokenOptions = {
system_type: SystemType.API,
token_type: TokenType.OAuthAccess,
const accessToken = crypto.randomBytes(16).toString('hex');
const refreshToken = crypto.randomBytes(20).toString('hex');
await OAuthToken.create({
token: crypto.createHash('sha256').update(accessToken).digest('hex'),
client_id: 'a2efa818a34fa16b8afbc8a74eba3eda', // TODO - This is the Wii U config, change this?
client_secret: 'c91cdb5658bd4954ade78533a339cf9a', // TODO - This is the Wii U config, change this?
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
info: {
system_type: SystemType.API,
token_type: TokenType.OAuthAccess,
title_id: BigInt(0),
issued: new Date(),
expires: new Date(Date.now() + (3600 * 1000))
}
});
const refreshTokenOptions = {
system_type: SystemType.API,
token_type: TokenType.OAuthRefresh,
await OAuthToken.create({
token: crypto.createHash('sha256').update(refreshToken).digest('hex'),
client_id: 'a2efa818a34fa16b8afbc8a74eba3eda', // TODO - This is the Wii U config, change this?
client_secret: 'c91cdb5658bd4954ade78533a339cf9a', // TODO - This is the Wii U config, change this?
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + 12 * 3600 * 1000)
};
const accessTokenBuffer = await generateToken(config.aes_key, accessTokenOptions);
const refreshTokenBuffer = await generateToken(config.aes_key, refreshTokenOptions);
if (!accessTokenBuffer) {
throw new ServerError(Status.INTERNAL, 'Failed to generate access token');
}
if (!refreshTokenBuffer) {
throw new ServerError(Status.INTERNAL, 'Failed to generate refresh token');
}
info: {
system_type: SystemType.API,
token_type: TokenType.OAuthRefresh,
title_id: BigInt(0),
issued: new Date(),
expires: new Date(Date.now() + 12 * 3600 * 1000)
}
});
return {
accessToken: accessTokenBuffer.toString('hex'),
accessToken: accessToken,
tokenType: 'Bearer',
expiresIn: 3600,
refreshToken: refreshTokenBuffer.toString('hex')
refreshToken: refreshToken
};
}

View File

@@ -1,9 +1,13 @@
import crypto from 'node:crypto';
import bcrypt from 'bcrypt';
import { Status, ServerError } from 'nice-grpc';
import { decryptToken, unpackToken, nintendoPasswordHash } from '@/util';
import { PasswordResetToken } from '@/models/password-reset-token';
import { nintendoPasswordHash } from '@/util';
import { getPNIDByPID } from '@/database';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
import type { ResetPasswordRequest, ResetPasswordResponse } from '@pretendonetwork/grpc/api/v2/reset_password_rpc';
import type { Token } from '@/types/common/token';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
// * This sucks
const PASSWORD_WORD_OR_NUMBER_REGEX = /(?=.*[a-zA-Z])(?=.*\d).*/;
@@ -20,20 +24,33 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing token');
}
let unpackedToken: Token;
let pnid: HydratedPNIDDocument | null = null;
try {
const decryptedToken = await decryptToken(Buffer.from(token, 'base64'));
unpackedToken = unpackToken(decryptedToken);
const passwordResetToken = await PasswordResetToken.findOne({
token: crypto.createHash('sha256').update(token).digest('hex')
});
if (!passwordResetToken) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
if (passwordResetToken.info.system_type !== SystemType.PasswordReset) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
if (passwordResetToken.info.token_type !== TokenType.PasswordReset) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
if (passwordResetToken.info.expires < new Date()) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
pnid = await getPNIDByPID(passwordResetToken.pid);
} catch {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
if (unpackedToken.expire_time < Date.now()) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Token expired');
}
const pnid = await getPNIDByPID(unpackedToken.pid);
if (!pnid) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token. No user found');
}
@@ -71,6 +88,7 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese
pnid.password = passwordHash;
await pnid.removeAllTokens();
await pnid.save();
return {};

View File

@@ -1,9 +1,12 @@
import crypto from 'node:crypto';
import express from 'express';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
import { nintendoBase64Encode, nintendoBase64Decode, nascDateTime, nascError, generateToken } from '@/util';
import { nintendoBase64Encode, nintendoBase64Decode, nascDateTime, nascError, createServiceToken } from '@/util';
import { getServerByTitleID } from '@/database';
import type { NASCRequestParams } from '@/types/services/nasc/request-params';
import { IndependentServiceToken } from '@/models/independent-service-token';
import { NEXToken } from '@/models/nex-token';
import type { NASCACRequestParams, NASCLoginACRequestParams, NASCServiceTokenACRequestParams } from '@/types/services/nasc/ac-request-params';
import type { HydratedServerDocument } from '@/types/mongoose/server';
const router = express.Router();
@@ -14,7 +17,7 @@ const router = express.Router();
* Description: Gets a NEX server address and token
*/
router.post('/', async (request: express.Request, response: express.Response): Promise<void> => {
const requestParams: NASCRequestParams = request.body;
const requestParams: NASCACRequestParams = request.body;
const action = nintendoBase64Decode(requestParams.action).toString();
const titleID = nintendoBase64Decode(requestParams.titleid).toString();
const gameServerID = nintendoBase64Decode(requestParams.gameid).toString();
@@ -61,60 +64,72 @@ router.post('/', async (request: express.Request, response: express.Response): P
switch (action) {
case 'LOGIN':
responseData = await processLoginRequest(server, nexAccount.pid, titleID);
responseData = await processLoginRequest(server, nexAccount.pid, requestParams as NASCLoginACRequestParams); // TODO - Remove this "as" with field checking
break;
case 'SVCLOC':
responseData = await processServiceTokenRequest(server, nexAccount.pid, titleID);
responseData = await processServiceTokenRequest(server, nexAccount.pid, requestParams as NASCServiceTokenACRequestParams); // TODO - Remove this "as" with field checking
break;
}
response.status(200).send(responseData.toString());
});
async function processLoginRequest(server: HydratedServerDocument, pid: number, titleID: string): Promise<URLSearchParams> {
const tokenOptions = {
system_type: SystemType.CTR,
token_type: TokenType.NEX,
async function processLoginRequest(server: HydratedServerDocument, pid: number, requestParams: NASCLoginACRequestParams): Promise<URLSearchParams> {
const titleID = nintendoBase64Decode(requestParams.titleid).toString();
const token = nintendoBase64Encode(crypto.randomBytes(112));
await NEXToken.create({
token: crypto.createHash('sha256').update(token).digest('hex'),
game_server_id: server.game_server_id,
pid: pid,
access_level: 0,
title_id: BigInt(parseInt(titleID, 16)),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
// TODO - Handle null tokens
const nexTokenBuffer = await generateToken(server.aes_key, tokenOptions);
const nexToken = nintendoBase64Encode(nexTokenBuffer || '');
info: {
system_type: SystemType.CTR,
token_type: TokenType.NEX,
title_id: BigInt(parseInt(titleID, 16)),
issued: new Date(),
expires: new Date(Date.now() + (3600 * 1000))
}
});
const connectInfo = await server.getServerConnectInfo();
return new URLSearchParams({
locator: nintendoBase64Encode(`${connectInfo.ip}:${connectInfo.port}`),
retry: nintendoBase64Encode('0'),
returncd: nintendoBase64Encode('001'),
token: nexToken,
token: token,
datetime: nintendoBase64Encode(nascDateTime())
});
}
async function processServiceTokenRequest(server: HydratedServerDocument, pid: number, titleID: string): Promise<URLSearchParams> {
const tokenOptions = {
system_type: SystemType.CTR,
token_type: TokenType.IndependentService,
async function processServiceTokenRequest(server: HydratedServerDocument, pid: number, requestParams: NASCServiceTokenACRequestParams): Promise<URLSearchParams> {
const titleID = nintendoBase64Decode(requestParams.titleid).toString();
const serviceTokenOptions = {
pid: pid,
access_level: 0,
title_id: BigInt(parseInt(titleID, 16)),
expire_time: BigInt(Date.now()) // TODO - Hack. Independent services expire their own tokens, so we give them the ISSUED time, not an EXPIRE time
title_id: titleID,
issued: new Date(),
expires: new Date(Date.now() + 24 * 3600 * 1000)
};
// TODO - Handle null tokens
const token = nintendoBase64Encode(createServiceToken(server, serviceTokenOptions));
const serviceTokenBuffer = await generateToken(server.aes_key, tokenOptions);
const serviceToken = nintendoBase64Encode(serviceTokenBuffer || '');
await IndependentServiceToken.create({
token: crypto.createHash('sha256').update(token).digest('hex'),
client_id: nintendoBase64Decode(requestParams.keyhash).toString(),
title_id: serviceTokenOptions.title_id,
pid: serviceTokenOptions.pid,
info: {
system_type: SystemType.CTR,
token_type: TokenType.IndependentService,
title_id: BigInt(parseInt(titleID, 16)),
issued: serviceTokenOptions.issued,
expires: serviceTokenOptions.expires
}
});
return new URLSearchParams({
retry: nintendoBase64Encode('0'),
returncd: nintendoBase64Encode('007'),
servicetoken: serviceToken,
servicetoken: token,
statusdata: nintendoBase64Encode('Y'),
svchost: nintendoBase64Encode('n/a'),
datetime: nintendoBase64Encode(nascDateTime())

View File

@@ -4,14 +4,14 @@ import got from 'got';
import { z } from 'zod';
import { getServerByClientID, getPNIDByPID } from '@/database';
import { LOG_ERROR } from '@/logger';
import { decryptToken, unpackToken, getValueFromHeaders, sendConfirmationEmail } from '@/util';
import { getValueFromHeaders, sendConfirmationEmail } from '@/util';
import { config } from '@/config-manager';
import { IndependentServiceToken } from '@/models/independent-service-token';
import timezones from '@/services/nnas/timezones.json';
import regionsList from '@/services/nnas/regions.json';
import type { HydratedServerDocument } from '@/types/mongoose/server';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
import type { AccountSettings } from '@/types/services/nnas/account-settings';
import type { Token } from '@/types/common/token';
import type { RegionLanguages } from '@/types/services/nnas/region-languages';
import type { RegionTimezone, RegionTimezones } from '@/types/services/nnas/region-timezones';
import type { Country, Region } from '@/types/services/nnas/regions';
@@ -44,13 +44,22 @@ router.get('/ui/profile', async function (request: express.Request, response: ex
return;
}
const aes_key: string = server?.aes_key;
const decryptedToken = decryptToken(Buffer.from(token, 'base64'), aes_key);
const serviceToken = await IndependentServiceToken.findOne({
token: crypto.createHash('sha256').update(token).digest('hex')
});
const tokenContents: Token = unpackToken(decryptedToken);
if (!serviceToken) {
response.sendStatus(504);
return;
}
if (serviceToken.client_id !== '3f3928cc6f780638d360f0485cef973f') {
response.sendStatus(504);
return;
}
try {
const PNID: HydratedPNIDDocument | null = await getPNIDByPID(tokenContents.pid);
const PNID: HydratedPNIDDocument | null = await getPNIDByPID(serviceToken.pid);
if (!PNID) {
response.sendStatus(504);
@@ -127,12 +136,22 @@ router.post('/update', async function (request: express.Request, response: expre
return;
}
const aesKey = server?.aes_key;
const decryptedToken = decryptToken(Buffer.from(token, 'base64'), aesKey);
const tokenContents: Token = unpackToken(decryptedToken);
const serviceToken = await IndependentServiceToken.findOne({
token: crypto.createHash('sha256').update(token).digest('hex')
});
if (!serviceToken) {
response.sendStatus(504);
return;
}
if (serviceToken.client_id !== '3f3928cc6f780638d360f0485cef973f') {
response.sendStatus(504);
return;
}
try {
const pnid: HydratedPNIDDocument | null = await getPNIDByPID(tokenContents.pid);
const pnid: HydratedPNIDDocument | null = await getPNIDByPID(serviceToken.pid);
const personBody: AccountSettings = request.body;
if (!pnid) {

View File

@@ -1,14 +1,14 @@
import crypto from 'node:crypto';
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import bcrypt from 'bcrypt';
import deviceCertificateMiddleware from '@/middleware/device-certificate';
import consoleStatusVerificationMiddleware from '@/middleware/console-status-verification';
import { getPNIDByNNASRefreshToken, getPNIDByUsername } from '@/database';
import { generateToken } from '@/util';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
import { config } from '@/config-manager';
import { Device } from '@/models/device';
import { OAuthToken } from '@/models/oauth-token';
const router = express.Router();
@@ -167,25 +167,39 @@ router.post('/access_token/generate', deviceCertificateMiddleware, consoleStatus
return;
}
const accessTokenOptions = {
system_type: SystemType.WUP,
token_type: TokenType.OAuthAccess,
const clientID = request.header('x-nintendo-client-id');
const clientSecret = request.header('x-nintendo-client-secret');
const accessToken = crypto.randomBytes(16).toString('hex');
const newRefreshToken = crypto.randomBytes(20).toString('hex');
await OAuthToken.create({
token: crypto.createHash('sha256').update(accessToken).digest('hex'),
client_id: clientID,
client_secret: clientSecret,
pid: pnid.pid,
expire_time: BigInt(Date.now() + (3600 * 1000))
};
info: {
system_type: SystemType.WUP,
token_type: TokenType.OAuthAccess,
title_id: BigInt(0), // TODO - Add this?
issued: new Date(),
expires: new Date(Date.now() + (3600 * 1000))
}
});
const refreshTokenOptions = {
system_type: SystemType.WUP,
token_type: TokenType.OAuthRefresh,
await OAuthToken.create({
token: crypto.createHash('sha256').update(newRefreshToken).digest('hex'),
client_id: clientID,
client_secret: clientSecret,
pid: pnid.pid,
expire_time: BigInt(Date.now() + 12 * 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') : '';
info: {
system_type: SystemType.WUP,
token_type: TokenType.OAuthRefresh,
title_id: BigInt(0), // TODO - Add this?
issued: new Date(),
expires: new Date(Date.now() + 12 * 3600 * 1000)
}
});
// TODO - Handle null tokens

View File

@@ -650,6 +650,8 @@ router.put('/@me', async (request: express.Request, response: express.Response):
const passwordHash = await bcrypt.hash(primaryPasswordHash, 10);
pnid.password = passwordHash;
await pnid.removeAllTokens();
}
pnid.gender = gender;

View File

@@ -1,8 +1,11 @@
import crypto from 'node:crypto';
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import { getServerByClientID, getServerByGameServerID } from '@/database';
import { generateToken, getValueFromHeaders, getValueFromQueryString } from '@/util';
import { createServiceToken, getValueFromHeaders, getValueFromQueryString } from '@/util';
import { TokenType } from '@/types/common/token-types';
import { IndependentServiceToken } from '@/models/independent-service-token';
import { NEXToken } from '@/models/nex-token';
import { NEXAccount } from '@/models/nex-account';
const router = express.Router();
@@ -90,25 +93,32 @@ router.get('/service_token/@me', async (request: express.Request, response: expr
return;
}
const tokenOptions = {
system_type: server.device,
token_type: TokenType.IndependentService,
const serviceTokenOptions = {
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(parseInt(titleID, 16)),
expire_time: BigInt(Date.now()) // TODO - Hack. Independent services expire their own tokens, so we give them the ISSUED time, not an EXPIRE time
title_id: titleID,
issued: new Date(),
expires: new Date(Date.now() + 24 * 3600 * 1000)
};
const serviceTokenBuffer = await generateToken(server.aes_key, tokenOptions);
let serviceToken = serviceTokenBuffer ? serviceTokenBuffer.toString('base64') : '';
const token = createServiceToken(server, serviceTokenOptions).toString('base64');
if (request.isCemu) {
serviceToken = Buffer.from(serviceToken, 'base64').toString('hex');
}
await IndependentServiceToken.create({
token: crypto.createHash('sha256').update(token).digest('hex'),
client_id: clientID,
title_id: serviceTokenOptions.title_id,
pid: serviceTokenOptions.pid,
info: {
system_type: server.device,
token_type: TokenType.IndependentService,
title_id: BigInt(parseInt(titleID, 16)),
issued: serviceTokenOptions.issued,
expires: serviceTokenOptions.expires
}
});
response.send(xmlbuilder.create({
service_token: {
token: serviceToken
token: token
}
}).end());
});
@@ -213,21 +223,20 @@ router.get('/nex_token/@me', async (request: express.Request, response: express.
return;
}
const tokenOptions = {
system_type: server.device,
token_type: TokenType.NEX,
const token = crypto.randomBytes(36).toString('base64');
await NEXToken.create({
token: crypto.createHash('sha256').update(token).digest('hex'),
game_server_id: gameServerID,
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(parseInt(titleID, 16)),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const nexTokenBuffer = await generateToken(server.aes_key, tokenOptions);
let nexToken = nexTokenBuffer ? nexTokenBuffer.toString('base64') : '';
if (request.isCemu) {
nexToken = Buffer.from(nexToken || '', 'base64').toString('hex');
}
info: {
system_type: server.device,
token_type: TokenType.NEX,
title_id: BigInt(parseInt(titleID, 16)),
issued: new Date(),
expires: new Date(Date.now() + (3600 * 1000))
}
});
const connectInfo = await server.getServerConnectInfo();
response.send(xmlbuilder.create({
@@ -236,7 +245,7 @@ router.get('/nex_token/@me', async (request: express.Request, response: express.
nex_password: nexAccount.password,
pid: nexAccount.pid,
port: connectInfo.port,
token: nexToken
token: token
}
}).end());
});

View File

@@ -0,0 +1,6 @@
export interface ServiceTokenOptions {
pid: number;
title_id: string;
issued: Date;
expires: Date;
}

View File

@@ -1,11 +0,0 @@
import type { SystemType } from '@/types/common/system-types';
import type { TokenType } from '@/types/common/token-types';
export interface TokenOptions {
system_type: SystemType;
token_type: TokenType;
pid: number;
access_level?: number;
title_id?: bigint;
expire_time: bigint;
}

View File

@@ -85,6 +85,7 @@ export interface IPNIDMethods {
generateMiiImages(): Promise<void>;
markForDeletion(): void;
scrub(): Promise<void>;
removeAllTokens(): Promise<void>;
hasPermission(flag: PNIDPermissionFlag): boolean;
addPermission(flag: PNIDPermissionFlag): void;
clearPermission(flag: PNIDPermissionFlag): void;

View File

@@ -0,0 +1,216 @@
/**
* Çommon request parameters found on all NASC `ac` requests.
* All fields are base64 encoded using Nintendo's custom alphabet:
* '+' -> '.', '/' -> '-', '=' -> '*'
*/
export interface NASCCommonACRequestParams {
/**
* Game server ID (`%08X`). This is the same as the `X-GameId` header.
* Derived from the games default title ID (usually the Japanese title ID)
*/
gameid: string;
/**
* Major and minor SDK version (`%03d%03d`). Always `000000`
*/
sdkver: string;
/**
* Title ID (`%016X`)
*/
titleid: string;
/**
* Product code. See https://3dsdb.com/
*/
gamecd: string;
/**
* Title version (`%04X`)
*/
gamever: string;
/**
* Game type
*
* - 0 = System
* - 1 = Digital
* - 2 = Cartridge
*/
mediatype: string;
/**
* Unique ROM (game) ID.
* Only present if the media type is 2 (cartridge)
*/
romid?: string;
/**
* Product maker (company code)
*/
makercd: string;
/**
* Unit code
*
* - 0 = NDS
* - 1 = Wii
* - 2 = 3DS
*/
unitcd: string;
/**
* Device MAC address
*/
macadr: string;
/**
* BSSID of active wifi network
*/
bssid: string;
/**
* Information about the used Wi-Fi access point in the format `AA:BBBBBBBBBB`.
* Example `01:0000000000`. `AA` is the AP slot. `BBBBBBBBBB` comes from either `ACU_GetNZoneApNumService` or `ACU_GetConnectingHotspotSubset` based on the result from `ACU_GetWifiStatus`
*/
apinfo: string;
/**
* LocalFriendCodeSeed_B
*/
fcdcert: string;
/**
* Device name (UTF-16-LE)
*/
devname: string;
/**
* Environment (`L1` for production)
*/
servertype: string;
/**
* FPD version (`%04X`). This is also included in the user agent.
*/
fpdver: string;
/**
* Current device time (`%y%m%d%H%M%S`)
*/
devtime: string;
/**
* Language code (`%02X`)
*/
lang: string;
/**
* Region code (`%02X`)
*/
region: string;
/**
* Serial number
*/
csnum: string;
/**
* The type of action the console wishes to perform:
*
* - LOGIN = Register new game server account or login to existing on
* - SVCLOC = Request service token
* - nzchk = Unknown, but seems related to Nintendo Zone
* - parse = Unknown
* - message = Unknown
*/
action: string;
}
/**
* Request parameters for when a console wants to register a new game server account.
* See also NASCCommonRequestParams.
* All fields are base64 encoded using Nintendo's custom alphabet:
* '+' -> '.', '/' -> '-', '=' -> '*'
*/
export interface NASCRegistrationACRequestParams extends NASCCommonACRequestParams {
/**
* Game server account password the console wishes to use for the new account.
* Can be any character between `\x21-\x5B` and `\x5D-\x7D`. Always 16 characters long
*/
passwd: string;
/**
* Nickname provided by game.
* Usually unused, leftover from the Wii
*/
ingamesn: string;
}
/**
* Request parameters for when a console wants to login to an existing game server account.
* See also NASCCommonRequestParams.
* All fields are base64 encoded using Nintendo's custom alphabet:
* '+' -> '.', '/' -> '-', '=' -> '*'
*/
export interface NASCLoginACRequestParams extends NASCCommonACRequestParams {
/**
* Hash of the user ID
*/
uidhmac: string;
/**
* Game server account user ID/username.
* Always the NEX account PID on 3DS
*/
userid: string;
/**
* Nickname provided by game.
* Usually unused, leftover from the Wii
*/
ingamesn: string;
}
/**
* Request parameters for when a console wants to request a service token.
* See also NASCCommonRequestParams.
* All fields are base64 encoded using Nintendo's custom alphabet:
* '+' -> '.', '/' -> '-', '=' -> '*'
*/
export interface NASCServiceTokenACRequestParams extends NASCCommonACRequestParams {
/**
* Hash of the user ID
*/
uidhmac: string;
/**
* Game server account user ID/username.
* Always the NEX account PID on 3DS
*/
userid: string;
/**
* Unique hash assigned to each game, regardless of title ID.
* Analogous to the NNAS client ID
*/
keyhash: string;
/**
* Service request type. Changes the `svchost` response field.
* This is likely a remnant from the original NAS/NASWII servers.
*
* - 0000 = "n/a"
* - 9001 = "dls1.nintendowifi.net"
*/
svc: string;
}
/**
* Union type representing all possible NASC request parameter types.
* The specific type used depends on the `action` field value:
*
* - `action: 'LOGIN'` = `NASCRegistrationRequestParams` or `NASCLoginRequestParams`
* - `action: 'SVCLOC'` = `NASCServiceTokenRequestParams`
*/
export type NASCACRequestParams = NASCRegistrationACRequestParams | NASCLoginACRequestParams | NASCServiceTokenACRequestParams;

View File

@@ -1,12 +0,0 @@
export interface NASCRequestParams {
action: string;
fcdcert: string;
csnum: string;
macadr: string;
titleid: string;
servertype: string;
gameid: string;
userid?: string;
uidhmac?: string;
passwd?: string;
}

View File

@@ -2,24 +2,23 @@ import crypto from 'node:crypto';
import path from 'node:path';
import { S3 } from '@aws-sdk/client-s3';
import fs from 'fs-extra';
import bufferCrc32 from 'buffer-crc32';
import { crc32 } from 'crc';
import { CronJob } from 'cron';
import { checkMarkedDeletions } from '@/database';
import { sendMail, CreateEmail } from '@/mailer';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
import { config, disabledFeatures } from '@/config-manager';
import { PasswordResetToken } from '@/models/password-reset-token';
import { LOG_ERROR } from '@/logger';
import type { IncomingHttpHeaders } from 'node:http';
import type { ParsedQs } from 'qs';
import type mongoose from 'mongoose';
import type express from 'express';
import type { ObjectCannedACL } from '@aws-sdk/client-s3';
import type { TokenOptions } from '@/types/common/token-options';
import type { Token } from '@/types/common/token';
import type { IPNID, IPNIDMethods } from '@/types/mongoose/pnid';
import type { SafeQs } from '@/types/common/safe-qs';
import type { HydratedServerDocument } from '@/types/mongoose/server';
import type { ServiceTokenOptions } from '@/types/common/service-token-options';
let s3: S3;
@@ -59,98 +58,22 @@ export function nintendoBase64Encode(decoded: string | Buffer): string {
return encoded.replaceAll('+', '.').replaceAll('/', '-').replaceAll('=', '*');
}
export function generateToken(key: string, options: TokenOptions): Buffer | null {
let dataBuffer = Buffer.alloc(1 + 1 + 4 + 8);
export function createServiceToken(server: HydratedServerDocument, options: ServiceTokenOptions): Buffer {
const dataBuffer = Buffer.alloc(28);
dataBuffer.writeUInt8(options.system_type, 0x0);
dataBuffer.writeUInt8(options.token_type, 0x1);
dataBuffer.writeUInt32LE(options.pid, 0x2);
dataBuffer.writeBigUInt64LE(options.expire_time, 0x6);
dataBuffer.writeUInt32BE(options.pid, 0);
dataBuffer.writeBigUInt64BE(BigInt(parseInt(options.title_id, 16)), 4);
dataBuffer.writeBigUInt64BE(BigInt(options.issued.getTime()), 12);
dataBuffer.writeBigUInt64BE(BigInt(options.expires.getTime()), 20);
if ((options.token_type !== TokenType.OAuthAccess && options.token_type !== TokenType.OAuthRefresh) || options.system_type === SystemType.API) {
// * 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;
}
// * Not using AES anymore but fuck it, it's here already
// TODO - rename the AES field
const hmac = crypto.createHmac('sha256', server.aes_key).update(dataBuffer).digest();
dataBuffer = Buffer.concat([
dataBuffer,
Buffer.alloc(8 + 1)
]);
dataBuffer.writeBigUInt64LE(options.title_id, 0xE);
dataBuffer.writeInt8(options.access_level, 0x16);
}
const iv = Buffer.alloc(16);
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(key, 'hex'), iv);
const encrypted = Buffer.concat([
cipher.update(dataBuffer),
cipher.final()
return Buffer.concat([
dataBuffer,
hmac
]);
let final = encrypted;
if ((options.token_type !== TokenType.OAuthAccess && options.token_type !== TokenType.OAuthRefresh) || options.system_type === SystemType.API) {
// * Access and refresh tokens don't get a checksum due to size constraints
const checksum = bufferCrc32(dataBuffer);
final = Buffer.concat([
checksum,
final
]);
}
return final;
}
export function decryptToken(token: Buffer, key?: string): Buffer {
let encryptedBody: Buffer;
let expectedChecksum = 0;
if (token.length === 16) {
// * Token is an access/refresh token, no checksum
encryptedBody = token;
} else {
expectedChecksum = token.readUint32BE();
encryptedBody = token.subarray(4);
}
if (!key) {
key = config.aes_key;
}
const iv = Buffer.alloc(16);
const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(key, 'hex'), iv);
const decrypted = Buffer.concat([
decipher.update(encryptedBody),
decipher.final()
]);
if (expectedChecksum && (expectedChecksum !== crc32(decrypted))) {
throw new Error('Checksum did not match. Failed decrypt. Are you using the right key?');
}
return decrypted;
}
export function unpackToken(token: Buffer): Token {
const unpacked: Token = {
system_type: token.readUInt8(0x0),
token_type: token.readUInt8(0x1),
pid: token.readUInt32LE(0x2),
expire_time: token.readBigUInt64LE(0x6)
};
if (unpacked.token_type !== TokenType.OAuthAccess && unpacked.token_type !== TokenType.OAuthRefresh) {
unpacked.title_id = token.readBigUInt64LE(0xE);
unpacked.access_level = token.readInt8(0x16);
}
return unpacked;
}
export function fullUrl(request: express.Request): string {
@@ -251,25 +174,25 @@ export async function sendEmailConfirmedParentalControlsEmail(pnid: mongoose.Hyd
}
export async function sendForgotPasswordEmail(pnid: mongoose.HydratedDocument<IPNID, IPNIDMethods>): Promise<void> {
const tokenOptions = {
system_type: SystemType.PasswordReset,
token_type: TokenType.PasswordReset,
const token = crypto.randomBytes(36).toString('hex');
await PasswordResetToken.create({
token: crypto.createHash('sha256').update(token).digest('hex'),
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 tokenBuffer = await generateToken(config.aes_key, tokenOptions);
const passwordResetToken = tokenBuffer ? tokenBuffer.toString('hex') : '';
// TODO - Handle null token
info: {
system_type: SystemType.PasswordReset,
token_type: TokenType.PasswordReset,
title_id: BigInt(0),
issued: new Date(),
expires: new Date(Date.now() + (24 * 60 * 60 * 1000))
}
});
const email = new CreateEmail()
.addHeader('Dear {{pnid}},', { pnid: pnid.username })
.addParagraph('a password reset has been requested from this account.')
.addParagraph('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.')
.addButton('Reset password', `${config.website_base}/account/reset-password?token=${encodeURIComponent(passwordResetToken)}`);
.addButton('Reset password', `${config.website_base}/account/reset-password?token=${encodeURIComponent(token)}`);
const mailerOptions = {
to: pnid.email.address,