mirror of
https://github.com/PretendoNetwork/account.git
synced 2026-08-19 17:26:37 -05:00
Merge branch 'dev'
This commit is contained in:
4503
package-lock.json
generated
4503
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -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",
|
||||
@@ -28,12 +28,13 @@
|
||||
"@aws-sdk/client-s3": "^3.657.0",
|
||||
"@aws-sdk/client-ses": "^3.515.0",
|
||||
"@inquirer/prompts": "^7.2.0",
|
||||
"@pretendonetwork/grpc": "^2.2.3",
|
||||
"@pretendonetwork/grpc": "^2.5.4",
|
||||
"bcrypt": "^5.0.0",
|
||||
"buffer-crc32": "^0.2.13",
|
||||
"colors": "^1.4.0",
|
||||
"cors": "^2.8.5",
|
||||
"crc": "^4.3.2",
|
||||
"cron": "^4.4.0",
|
||||
"dicer": "^0.2.5",
|
||||
"dotenv": "^16.0.3",
|
||||
"ejs": "^3.1.10",
|
||||
@@ -47,7 +48,6 @@
|
||||
"image-pixels": "^1.1.1",
|
||||
"ip2location-nodejs": "^9.6.3",
|
||||
"is-valid-hostname": "^1.0.2",
|
||||
"joi": "^17.8.3",
|
||||
"mii-js": "github:PretendoNetwork/mii-js#f1741e1f82771dd7c753fd408230373d33caa184",
|
||||
"moment": "^2.29.4",
|
||||
"mongoose": "^7.0.0",
|
||||
@@ -68,7 +68,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hcaptcha/types": "^1.0.3",
|
||||
"@pretendonetwork/eslint-config": "^0.0.8",
|
||||
"@pretendonetwork/eslint-config": "^0.1.4",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/buffer-crc32": "^0.2.2",
|
||||
"@types/cors": "^2.8.13",
|
||||
@@ -89,5 +89,8 @@
|
||||
"globals": "^15.14.0",
|
||||
"ndarray": "^1.0.19",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"overrides": {
|
||||
"@bufbuild/protobuf": "2.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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';
|
||||
@@ -12,18 +13,10 @@ import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
|
||||
import type { IDeviceAttribute } from '@/types/mongoose/device-attribute';
|
||||
import type { HydratedServerDocument } from '@/types/mongoose/server';
|
||||
import type { PNIDProfile } from '@/types/services/nnas/pnid-profile';
|
||||
import type { ConnectionData } from '@/types/services/api/connection-data';
|
||||
import type { ConnectionResponse } from '@/types/services/api/connection-response';
|
||||
import type { DiscordConnectionData } from '@/types/services/api/discord-connection-data';
|
||||
|
||||
const connection_string = config.mongoose.connection_string;
|
||||
const options = config.mongoose.options;
|
||||
|
||||
// TODO - Extend this later with more settings
|
||||
const discordConnectionSchema = joi.object({
|
||||
id: joi.string()
|
||||
});
|
||||
|
||||
const accessModeOrder: Record<string, string[]> = {
|
||||
prod: ['prod'],
|
||||
test: ['test', 'prod'],
|
||||
@@ -110,26 +103,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
|
||||
@@ -291,51 +295,20 @@ export async function getServerByClientID(clientID: string, accessMode: string):
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function addPNIDConnection(pnid: HydratedPNIDDocument, data: ConnectionData, type: string): Promise<ConnectionResponse | undefined> {
|
||||
if (type === 'discord') {
|
||||
return await addPNIDConnectionDiscord(pnid, data);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addPNIDConnectionDiscord(pnid: HydratedPNIDDocument, data: DiscordConnectionData): Promise<ConnectionResponse> {
|
||||
const valid = discordConnectionSchema.validate(data);
|
||||
|
||||
if (valid.error) {
|
||||
return {
|
||||
app: 'api',
|
||||
status: 400,
|
||||
error: 'Invalid or missing connection data'
|
||||
};
|
||||
}
|
||||
|
||||
await PNID.updateOne({ pid: pnid.pid }, {
|
||||
$set: {
|
||||
'connections.discord.id': data.id
|
||||
export async function checkMarkedDeletions(): Promise<void> {
|
||||
const pnids = await PNID.find({
|
||||
marked_for_deletion: true,
|
||||
deleted: false,
|
||||
hard_delete_time: {
|
||||
$lte: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
app: 'api',
|
||||
status: 200
|
||||
};
|
||||
}
|
||||
|
||||
export async function removePNIDConnection(pnid: HydratedPNIDDocument, type: string): Promise<ConnectionResponse | undefined> {
|
||||
// * Add more connections later?
|
||||
if (type === 'discord') {
|
||||
return await removePNIDConnectionDiscord(pnid);
|
||||
for (const pnid of pnids) {
|
||||
try {
|
||||
await pnid.scrub();
|
||||
} catch (error) {
|
||||
LOG_ERROR(`Failed to scrub PNID ${pnid.pid}: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function removePNIDConnectionDiscord(pnid: HydratedPNIDDocument): Promise<ConnectionResponse> {
|
||||
await PNID.updateOne({ pid: pnid.pid }, {
|
||||
$set: {
|
||||
'connections.discord.id': ''
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
app: 'api',
|
||||
status: 200
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -232,31 +232,27 @@ async function NASCMiddleware(request: express.Request, response: express.Respon
|
||||
return next();
|
||||
}
|
||||
|
||||
// * https://www.adminsub.net/mac-address-finder/nintendo
|
||||
// * https://standards-oui.ieee.org
|
||||
// * Saves us from doing an OUI lookup each time
|
||||
const NINTENDO_VENDER_OUIS = [
|
||||
'ECC40D', 'E84ECE', 'E0F6B5', 'E0E751', 'E00C7F', 'DC68EB',
|
||||
'D86BF7', 'D4F057', 'CCFB65', 'CC9E00', 'B8AE6E', 'B88AEC',
|
||||
'B87826', 'A4C0E1', 'A45C27', 'A438CC', '9CE635', '98E8FA',
|
||||
'98B6E9', '98415C', '9458CB', '8CCDE8', '8C56C5', '7CBB8A',
|
||||
'78A2A0', '7048F7', '64B5C6', '606BFF', '5C521E', '58BDA3',
|
||||
'582F40', '48A5E7', '40F407', '40D28A', '34AF2C', '342FBD',
|
||||
'2C10C1', '182A7B', '0403D6', '002709', '002659', '0025A0',
|
||||
'0024F3', '002444', '00241E', '0023CC', '002331', '0022D7',
|
||||
'0022AA', '00224C', '0021BD', '002147', '001FC5', '001F32',
|
||||
'001EA9', '001E35', '001DBC', '001CBE', '001BEA', '001B7A',
|
||||
'001AE9', '0019FD', '00191D', '0017AB', '001656', '0009BF',
|
||||
'ECC40D', 'E84ECE', 'E0F6B5', 'E0E751', 'E00C7F', 'DC68EB',
|
||||
'D86BF7', 'D4F057', 'CCFB65', 'CC9E00', 'B8AE6E', 'B88AEC',
|
||||
'B87826', 'A4C0E1', 'A45C27', 'A438CC', '9CE635', '98E8FA',
|
||||
'98B6E9', '98415C', '9458CB', '8CCDE8', '8C56C5', '7CBB8A',
|
||||
'78A2A0', '7048F7', '64B5C6', '606BFF', '5C521E', '58BDA3',
|
||||
'582F40', '48A5E7', '40F407', '40D28A', '34AF2C', '342FBD',
|
||||
'2C10C1', '182A7B', '0403D6', '002709', '002659', '0025A0',
|
||||
'0024F3', '002444', '00241E', '0023CC', '002331', '0022D7',
|
||||
'0022AA', '00224C', '0021BD', '002147', '001FC5', '001F32',
|
||||
'001EA9', '001E35', '001DBC', '001CBE', '001BEA', '001B7A',
|
||||
'001AE9', '0019FD', '00191D', '0017AB', '001656', '0009BF'
|
||||
'601AC7', 'BC9EBB', 'CC5B31', '1C4586', 'E8A0CD', '702C09',
|
||||
'7048F7', '98E8FA', 'ECC40D', '606BFF', '64B5C6', '40D28A',
|
||||
'A45C27', '8C56C5', '002659', '00241E', '002444', '98E255',
|
||||
'E0EFBF', '948E6D', '38C6CE', 'C89143', 'DCCD18', '28CF51',
|
||||
'58B03E', '200BCF', '748469', '70F088', '9458CB', '582F40',
|
||||
'B88AEC', 'A438CC', '40F407', 'A4C0E1', '0022D7', '001CBE',
|
||||
'001B7A', '001AE9', '0009BF', '904528', 'ACFAE4', 'BC89A6',
|
||||
'201C3A', '7820A5', 'E0F6B5', '342FBD', '98415C', 'D4F057',
|
||||
'5C521E', '98B6E9', 'CCFB65', 'B8AE6E', '182A7B', '2C10C1',
|
||||
'002331', '001E35', '001BEA', '0017AB', '001656', 'BC744B',
|
||||
'3CA9AB', 'C84805', 'C0A4CF', '3089EC', '483177', '50236D',
|
||||
'D05509', 'E8DA20', '7CBB8A', '34AF2C', '78A2A0', 'E84ECE',
|
||||
'002709', '0025A0', '0024F3', '0023CC', '001F32', '001EA9',
|
||||
'001DBC', '0019FD', '00191D', 'A4C1E8', 'D86B83', '4044F7',
|
||||
'B86870', 'BCCE25', '80D2E5', '5C0CE6', '74F9CA', '48A5E7',
|
||||
'B87826', 'DC68EB', '0403D6', '9CE635', '8CCDE8', '58BDA3',
|
||||
'E00C7F', 'CC9E00', 'D86BF7', 'E0E751', '0022AA', '00224C',
|
||||
'0021BD', '002147', '001FC5', '48F1EB', '78818C', '4C306A'
|
||||
];
|
||||
|
||||
// TODO - Make something better
|
||||
|
||||
@@ -53,7 +53,7 @@ async function PNIDMiddleware(request: express.Request, response: express.Respon
|
||||
return;
|
||||
}
|
||||
|
||||
if (pnid.deleted) {
|
||||
if (pnid.deleted || pnid.marked_for_deletion) {
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import crypto from 'node:crypto';
|
||||
import ratelimit from 'express-rate-limit';
|
||||
import { getValueFromHeaders } from '@/util';
|
||||
import { getValueFromHeaders, nascError } from '@/util';
|
||||
import type express from 'express';
|
||||
|
||||
export default ratelimit({
|
||||
export const deviceRatelimit = ratelimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 1,
|
||||
keyGenerator: (request: express.Request): string => {
|
||||
@@ -16,3 +16,50 @@ export default ratelimit({
|
||||
return crypto.createHash('md5').update(data!).digest('hex');
|
||||
}
|
||||
});
|
||||
|
||||
export const loginRatelimit = ratelimit({
|
||||
windowMs: 5 * 60 * 1000, // 5mins
|
||||
max: 20,
|
||||
keyGenerator: (request: express.Request): string => {
|
||||
const grantType = request.body?.grant_type;
|
||||
const username = request.body?.username?.trim();
|
||||
const refreshToken = request.body?.refresh_token?.trim();
|
||||
|
||||
let data = request.ip;
|
||||
// Mix in user identification to make CGNAT less harsh
|
||||
if (grantType == 'password') {
|
||||
data += String(username);
|
||||
} else if (grantType == 'refresh_token') {
|
||||
data += String(refreshToken);
|
||||
}
|
||||
|
||||
return crypto.createHash('md5').update(data!).digest('hex');
|
||||
}
|
||||
});
|
||||
|
||||
export const webRegisterRatelimit = ratelimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 5, // lax for CGNAT
|
||||
keyGenerator: (request: express.Request): string => {
|
||||
const data = request.body.ip?.trim(); // forwarded from web
|
||||
|
||||
return crypto.createHash('md5').update(data!).digest('hex');
|
||||
}
|
||||
});
|
||||
|
||||
export const passwordResetRatelimit = ratelimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 10 // lax for CGNAT
|
||||
});
|
||||
|
||||
export const nascRatelimit = ratelimit({
|
||||
windowMs: 5 * 60 * 1000, // 5mins
|
||||
max: 30,
|
||||
keyGenerator: (request: express.Request): string => {
|
||||
const nexAccount = request.nexAccount;
|
||||
const pid = nexAccount?.pid ?? 0;
|
||||
|
||||
return String(pid);
|
||||
},
|
||||
message: nascError('null')
|
||||
});
|
||||
|
||||
45
src/models/independent-service-token.ts
Normal file
45
src/models/independent-service-token.ts
Normal 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
43
src/models/nex-token.ts
Normal 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
45
src/models/oauth-token.ts
Normal 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);
|
||||
41
src/models/password-reset-token.ts
Normal file
41
src/models/password-reset-token.ts
Normal 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);
|
||||
@@ -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';
|
||||
|
||||
@@ -33,6 +37,11 @@ const PNIDSchema = new Schema<IPNID, PNIDModel, IPNIDMethods>({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
marked_for_deletion: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hard_delete_time: Date,
|
||||
permissions: {
|
||||
type: BigInt,
|
||||
default: 0n
|
||||
@@ -125,6 +134,11 @@ const PNIDSchema = new Schema<IPNID, PNIDModel, IPNIDMethods>({
|
||||
}
|
||||
}, { id: false });
|
||||
|
||||
PNIDSchema.index({ pid: 1 });
|
||||
PNIDSchema.index({ usernameLower: 1 });
|
||||
// Used for the admin panel querying
|
||||
PNIDSchema.index({ 'pid': 1, 'username': 1, 'connections.discord.id': 1 });
|
||||
|
||||
PNIDSchema.plugin(uniqueValidator, { message: '{PATH} already in use.' });
|
||||
|
||||
/*
|
||||
@@ -225,6 +239,39 @@ PNIDSchema.method('generateMiiImages', async function generateMiiImages(): Promi
|
||||
await uploadCDNAsset(config.s3.bucket, `${userMiiKey}/body.png`, miiStudioBodyImageData, 'public-read');
|
||||
});
|
||||
|
||||
PNIDSchema.method('markForDeletion', async function markForDeletion() {
|
||||
this.marked_for_deletion = true;
|
||||
this.hard_delete_time = new Date(Date.now() + (7 * 24 * 3600 * 1000)); // * 7 day grace period
|
||||
|
||||
if (this.connections?.stripe?.subscription_id) {
|
||||
const subscriptionID = this.connections.stripe.subscription_id;
|
||||
|
||||
try {
|
||||
if (stripe) {
|
||||
// * If a user has an active subscription when they mark themselves for deletion, then they
|
||||
// * may get charged again in those 7 days while not having access to their account. To prevent
|
||||
// * that, just update the subscription to cancel at the end of the period. That way the charge
|
||||
// * doesn't happen, and the user likely won't be restoring their account anyway. If they do
|
||||
// * restore the account and do want their subscription back then they can create a new one
|
||||
// * after this one expires which is effectively the same as the old subscription renewing.
|
||||
// * Pausing collection is another option however it requires much more effort on our end to
|
||||
// * properly resume the paused charges and recitify any missed ones to prevent invoices
|
||||
// * from being skipped. This method is just way easier for us to deal with right now, at the
|
||||
// * expense of making the user do a tad more work on their end
|
||||
await stripe.subscriptions.update(subscriptionID, {
|
||||
cancel_at_period_end: true
|
||||
});
|
||||
} else {
|
||||
LOG_WARN(`UPDATING SUBSCRIPTION FOR USER ${this.username}. HAS STRIPE DATA UDER ID ${this.connections.stripe.customer_id}, BUT STRIPE CLIENT NOT ENABLED.`);
|
||||
}
|
||||
} catch (error) {
|
||||
LOG_ERROR(`ERROR UPDATING SUBSCRIPTION FOR USER ${this.username} (${subscriptionID}). ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.save();
|
||||
});
|
||||
|
||||
PNIDSchema.method('scrub', async function scrub() {
|
||||
// * Remove all personal info from a PNID
|
||||
// * Username and PID remain so thye do not get assigned again
|
||||
@@ -287,7 +334,10 @@ PNIDSchema.method('scrub', async function scrub() {
|
||||
});
|
||||
|
||||
this.deleted = true;
|
||||
this.access_level = 0;
|
||||
this.marked_for_deletion = false;
|
||||
if (this.access_level > 0) {
|
||||
this.access_level = 0;
|
||||
}
|
||||
this.server_access_level = 'prod';
|
||||
this.creation_date = '';
|
||||
this.birthdate = '';
|
||||
@@ -318,6 +368,17 @@ PNIDSchema.method('scrub', async function scrub() {
|
||||
this.connections.stripe.tier_level = 0;
|
||||
this.connections.stripe.tier_name = '';
|
||||
this.connections.stripe.latest_webhook_timestamp = 0;
|
||||
|
||||
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 {
|
||||
|
||||
@@ -98,19 +98,24 @@ ServerSchema.method('getServerConnectInfo', async function (): Promise<IServerCo
|
||||
// * and just Hope For The Best:tm:
|
||||
let target = randomIP;
|
||||
|
||||
// * Check the random IP and start the race at the same time, preferring
|
||||
// * the result of the random IP should it succeed. Worst case scenario
|
||||
// * this takes 2 seconds to complete
|
||||
const [randomResult, raceResult] = await Promise.allSettled([
|
||||
healthCheck({ host: randomIP, port: this.health_check_port! }),
|
||||
Promise.race(healthCheckTargets.map(target => healthCheck(target)))
|
||||
]);
|
||||
// * If the server only has 1 IP, healthCheckTargets will be empty and Promise.race
|
||||
// * will never resolve. In those cases, just skip this step entirely since we'd
|
||||
// * have to use that singular IP regardless
|
||||
if (healthCheckTargets.length !== 0) {
|
||||
// * Check the random IP and start the race at the same time, preferring
|
||||
// * the result of the random IP should it succeed. Worst case scenario
|
||||
// * this takes 2 seconds to complete
|
||||
const [randomResult, raceResult] = await Promise.allSettled([
|
||||
healthCheck({ host: randomIP, port: this.health_check_port! }),
|
||||
Promise.race(healthCheckTargets.map(target => healthCheck(target)))
|
||||
]);
|
||||
|
||||
if (randomResult.status === 'rejected') {
|
||||
if (raceResult.status === 'fulfilled') {
|
||||
target = raceResult.value;
|
||||
} else {
|
||||
LOG_WARN(`Server ${this.service_name} failed to find healthy NEX server. Using the randomly selected IP ${target}`);
|
||||
if (randomResult.status === 'rejected') {
|
||||
if (raceResult.status === 'fulfilled') {
|
||||
target = raceResult.value;
|
||||
} else {
|
||||
LOG_WARN(`Server ${this.service_name} failed to find healthy NEX server. Using the randomly selected IP ${target}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { z } from 'zod';
|
||||
import mongoose from 'mongoose';
|
||||
import { config, disabledFeatures } from './config-manager';
|
||||
import { LOG_INFO, LOG_WARN } from './logger';
|
||||
import { Server } from './models/server';
|
||||
import { config, disabledFeatures } from '@/config-manager';
|
||||
import { LOG_INFO, LOG_WARN } from '@/logger';
|
||||
import { Server } from '@/models/server';
|
||||
|
||||
// Provisioning has a couple edgecases:
|
||||
// - It will only update existing entries, will not add new one
|
||||
// - Only the fields in the below schema will be updated
|
||||
// - Set fields to null to unset them. Undefined means it won't be modified
|
||||
|
||||
const serverProvisioningSchema = z.object({
|
||||
servers: z.array(z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
ip: z.string().optional(),
|
||||
ipList: z.array(z.string()).optional(),
|
||||
port: z.coerce.number(),
|
||||
health_check_port: z.coerce.number().optional()
|
||||
name: z.string().optional(),
|
||||
ip: z.string().nullable().optional(),
|
||||
ip_list: z.array(z.string()).optional(),
|
||||
port: z.number().optional(),
|
||||
health_check_port: z.number().nullable().optional(),
|
||||
aes_key: z.string().optional()
|
||||
}))
|
||||
});
|
||||
|
||||
@@ -42,10 +44,11 @@ export async function handleServerProvisioning(): Promise<void> {
|
||||
$set: {
|
||||
_id: id,
|
||||
service_name: server.name,
|
||||
ipList: server.ipList,
|
||||
ip_list: server.ip_list,
|
||||
ip: server.ip,
|
||||
port: server.port,
|
||||
health_check_port: server.health_check_port
|
||||
health_check_port: server.health_check_port,
|
||||
aes_key: server.aes_key
|
||||
}
|
||||
});
|
||||
if (!result) {
|
||||
|
||||
@@ -3,9 +3,9 @@ import morgan from 'morgan';
|
||||
import xmlbuilder from 'xmlbuilder';
|
||||
import xmlparser from '@/middleware/xml-parser';
|
||||
import { connect as connectCache } from '@/cache';
|
||||
import { connect as connectDatabase } from '@/database';
|
||||
import { checkMarkedDeletions, connect as connectDatabase } from '@/database';
|
||||
import { startGRPCServer } from '@/services/grpc/server';
|
||||
import { fullUrl, getValueFromHeaders } from '@/util';
|
||||
import { fullUrl, getValueFromHeaders, setupScheduledTasks } from '@/util';
|
||||
import { LOG_INFO, LOG_SUCCESS, LOG_WARN } from '@/logger';
|
||||
import conntest from '@/services/conntest';
|
||||
import cbvc from '@/services/cbvc';
|
||||
@@ -16,7 +16,7 @@ import api from '@/services/api';
|
||||
import localcdn from '@/services/local-cdn';
|
||||
import assets from '@/services/assets';
|
||||
import { config, disabledFeatures } from '@/config-manager';
|
||||
import { startProvisioner } from './provisioning';
|
||||
import { startProvisioner } from '@/provisioning';
|
||||
|
||||
process.title = 'Pretendo - Account';
|
||||
process.on('uncaughtException', (err, origin) => {
|
||||
@@ -116,6 +116,10 @@ async function main(): Promise<void> {
|
||||
|
||||
startProvisioner();
|
||||
|
||||
await checkMarkedDeletions();
|
||||
|
||||
setupScheduledTasks();
|
||||
|
||||
app.listen(config.http.port, () => {
|
||||
LOG_SUCCESS(`HTTP server started on port ${config.http.port}`);
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ api.options('*', cors());
|
||||
|
||||
// * Setup routes
|
||||
LOG_INFO('[USER API] Applying imported routes');
|
||||
api.use('/v1/connections', V1.CONNECTIONS);
|
||||
api.use('/v1/email', V1.EMAIL);
|
||||
api.use('/v1/forgot-password', V1.FORGOT_PASSWORD);
|
||||
api.use('/v1/login', V1.LOGIN);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import connections_v1 from '@/services/api/routes/v1/connections';
|
||||
import email_v1 from '@/services/api/routes/v1/email';
|
||||
import forgotPassword_v1 from '@/services/api/routes/v1/forgotPassword';
|
||||
import login_v1 from '@/services/api/routes/v1/login';
|
||||
@@ -7,7 +6,6 @@ import resetPassword_v1 from '@/services/api/routes/v1/resetPassword';
|
||||
import user_v1 from '@/services/api/routes/v1/user';
|
||||
|
||||
export const V1 = {
|
||||
CONNECTIONS: connections_v1,
|
||||
EMAIL: email_v1,
|
||||
FORGOT_PASSWORD: forgotPassword_v1,
|
||||
LOGIN: login_v1,
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import express from 'express';
|
||||
import { addPNIDConnection, removePNIDConnection } from '@/database';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const VALID_CONNECTION_TYPES = [
|
||||
'discord'
|
||||
];
|
||||
|
||||
/**
|
||||
* [POST]
|
||||
* Implementation of for: https://api.pretendo.cc/v1/connections/add/TYPE
|
||||
* Description: Adds an account connection to the users PNID
|
||||
*/
|
||||
router.post('/add/:type', async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
const data = request.body?.data;
|
||||
const pnid = request.pnid;
|
||||
const type = request.params.type;
|
||||
|
||||
if (!pnid) {
|
||||
response.status(400).json({
|
||||
app: 'api',
|
||||
status: 400,
|
||||
error: 'Invalid or missing access token'
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
response.status(400).json({
|
||||
app: 'api',
|
||||
status: 400,
|
||||
error: 'Invalid or missing connection data'
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!VALID_CONNECTION_TYPES.includes(type)) {
|
||||
response.status(400).json({
|
||||
app: 'api',
|
||||
status: 400,
|
||||
error: 'Invalid or missing connection type'
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let result = await addPNIDConnection(pnid, data, type);
|
||||
|
||||
if (!result) {
|
||||
result = {
|
||||
app: 'api',
|
||||
status: 500,
|
||||
error: 'Unknown server error'
|
||||
};
|
||||
}
|
||||
|
||||
response.status(result.status || 500).json(result);
|
||||
});
|
||||
|
||||
/**
|
||||
* [DELETE]
|
||||
* Implementation of for: https://api.pretendo.cc/v1/connections/remove/TYPE
|
||||
* Description: Removes an account connection from the users PNID
|
||||
*/
|
||||
router.delete('/remove/:type', async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
const pnid = request.pnid;
|
||||
const type = request.params.type;
|
||||
|
||||
if (!pnid) {
|
||||
response.status(400).json({
|
||||
app: 'api',
|
||||
status: 400,
|
||||
error: 'Invalid or missing access token'
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!VALID_CONNECTION_TYPES.includes(type)) {
|
||||
response.status(400).json({
|
||||
app: 'api',
|
||||
status: 400,
|
||||
error: 'Invalid or missing connection type'
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let result = await removePNIDConnection(pnid, type);
|
||||
|
||||
if (!result) {
|
||||
result = {
|
||||
app: 'api',
|
||||
status: 500,
|
||||
error: 'Unknown server error'
|
||||
};
|
||||
}
|
||||
|
||||
response.status(result.status).json(result);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,11 +1,13 @@
|
||||
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 { loginRatelimit } from '@/middleware/ratelimit';
|
||||
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -16,7 +18,7 @@ const router = express.Router();
|
||||
* Description: Generates an access token for an API user
|
||||
* TODO: Replace this with a more robust OAuth2 implementation
|
||||
*/
|
||||
router.post('/', async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
router.post('/', loginRatelimit, async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
const grantType = request.body?.grant_type;
|
||||
const username = request.body?.username;
|
||||
const password = request.body?.password;
|
||||
@@ -102,7 +104,7 @@ router.post('/', async (request: express.Request, response: express.Response): P
|
||||
}
|
||||
}
|
||||
|
||||
if (pnid.deleted) {
|
||||
if (pnid.deleted || pnid.marked_for_deletion) {
|
||||
response.status(400).json({
|
||||
app: 'api',
|
||||
status: 400,
|
||||
@@ -112,30 +114,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
|
||||
|
||||
|
||||
@@ -6,14 +6,16 @@ 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 { webRegisterRatelimit } from '@/middleware/ratelimit';
|
||||
import type { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account';
|
||||
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
|
||||
|
||||
@@ -37,7 +39,7 @@ const DEFAULT_MII_DATA = Buffer.from('AwAAQOlVognnx0GC2/uogAOzuI0n2QAAAEBEAGUAZg
|
||||
* Implementation of: https://api.pretendo.cc/v1/register
|
||||
* Description: Creates a new user PNID
|
||||
*/
|
||||
router.post('/', async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
router.post('/', webRegisterRatelimit, async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
const clientIP = request.body.ip?.trim(); // * This has to be forwarded since this request comes from the websites server
|
||||
const birthday = request.body.birthday?.trim();
|
||||
const email = request.body.email?.trim();
|
||||
@@ -118,6 +120,22 @@ router.post('/', async (request: express.Request, response: express.Response): P
|
||||
}
|
||||
}
|
||||
|
||||
if (age < 13) {
|
||||
// * Wii U firmware 5.5.6 changed NNID setup to block setup of new accounts if the users age is
|
||||
// * under 13, telling parents that they MUST call Nintendo to create the account, and we trusted
|
||||
// * that users would be on these firmwares. Lower firmwares won't have this though, and will use
|
||||
// * the old "COPPA approval" system
|
||||
// *
|
||||
// * Just block it all the time though, we don't want to deal with this headache
|
||||
response.status(400).json({
|
||||
app: 'api',
|
||||
status: 400,
|
||||
error: 'Must be 13 or older to use these services.'
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email || email === '') {
|
||||
response.status(400).json({
|
||||
app: 'api',
|
||||
@@ -419,32 +437,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,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
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 { passwordResetRatelimit } from '@/middleware/ratelimit';
|
||||
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -12,7 +17,7 @@ const PASSWORD_WORD_OR_PUNCTUATION_REGEX = /(?=.*[a-zA-Z])(?=.*[_\-.]).*/;
|
||||
const PASSWORD_NUMBER_OR_PUNCTUATION_REGEX = /(?=.*\d)(?=.*[_\-.]).*/;
|
||||
const PASSWORD_REPEATED_CHARACTER_REGEX = /(.)\1\1/;
|
||||
|
||||
router.post('/', async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
router.post('/', passwordResetRatelimit, async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
const password = request.body.password?.trim();
|
||||
const passwordConfirm = request.body.password_confirm?.trim();
|
||||
const token = request.body.token?.trim();
|
||||
@@ -27,10 +32,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 +89,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 +174,7 @@ router.post('/', async (request: express.Request, response: express.Response): P
|
||||
|
||||
pnid.password = passwordHash;
|
||||
|
||||
await pnid.removeAllTokens();
|
||||
await pnid.save();
|
||||
|
||||
response.json({
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function exchangeTokenForUserData(request: ExchangeTokenForUserData
|
||||
}
|
||||
|
||||
return {
|
||||
deleted: pnid.deleted,
|
||||
deleted: pnid.deleted || pnid.marked_for_deletion,
|
||||
pid: pnid.pid,
|
||||
username: pnid.username,
|
||||
accessLevel: pnid.access_level,
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function getUserData(request: GetUserDataRequest): Promise<GetUserD
|
||||
}
|
||||
|
||||
return {
|
||||
deleted: pnid.deleted,
|
||||
deleted: pnid.deleted || pnid.marked_for_deletion,
|
||||
pid: pnid.pid,
|
||||
username: pnid.username,
|
||||
accessLevel: pnid.access_level,
|
||||
|
||||
9
src/services/grpc/account/v2/create-audit-log-comment.ts
Normal file
9
src/services/grpc/account/v2/create-audit-log-comment.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
9
src/services/grpc/account/v2/create-ban-comment.ts
Normal file
9
src/services/grpc/account/v2/create-ban-comment.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
9
src/services/grpc/account/v2/create-server.ts
Normal file
9
src/services/grpc/account/v2/create-server.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Status, ServerError } from 'nice-grpc';
|
||||
import { getPNIDByPID } from '@/database';
|
||||
import { sendPNIDDeletedEmail } from '@/util';
|
||||
import { LOG_ERROR } from '@/logger';
|
||||
import { LOG_ERROR, LOG_INFO } from '@/logger';
|
||||
import type { DeleteAccountRequest, DeleteAccountResponse } from '@pretendonetwork/grpc/account/v2/delete_account_rpc';
|
||||
|
||||
export async function deleteAccount(request: DeleteAccountRequest): Promise<DeleteAccountResponse> {
|
||||
@@ -15,17 +15,27 @@ export async function deleteAccount(request: DeleteAccountRequest): Promise<Dele
|
||||
}
|
||||
|
||||
try {
|
||||
LOG_INFO(`Deleting PNID ${pnid.pid} (bypass grace period=${request.bypassGracePeriod})`);
|
||||
const email = pnid.email.address;
|
||||
|
||||
await pnid.scrub();
|
||||
await pnid.save();
|
||||
if (request.bypassGracePeriod) {
|
||||
await pnid.scrub();
|
||||
} else {
|
||||
await pnid.markForDeletion();
|
||||
}
|
||||
|
||||
await sendPNIDDeletedEmail(email, pnid.username);
|
||||
|
||||
if (request.bypassGracePeriod) {
|
||||
LOG_INFO(`PNID ${pnid.pid} deleted immediately (bypassed grace period)`);
|
||||
} else {
|
||||
LOG_INFO(`PNID ${pnid.pid} marked for deletion (will be deleted after grace period)`);
|
||||
}
|
||||
} catch (error) {
|
||||
LOG_ERROR(`Deleting PNID ${error}`);
|
||||
LOG_ERROR(`Error deleting PNID ${pnid.pid}: ${error}`);
|
||||
}
|
||||
|
||||
return {
|
||||
hasDeleted: pnid.deleted
|
||||
hasDeleted: pnid.deleted || pnid.marked_for_deletion
|
||||
};
|
||||
}
|
||||
|
||||
9
src/services/grpc/account/v2/delete-pnid.ts
Normal file
9
src/services/grpc/account/v2/delete-pnid.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
9
src/services/grpc/account/v2/delete-server.ts
Normal file
9
src/services/grpc/account/v2/delete-server.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -2,11 +2,9 @@ import { Status, ServerError } from 'nice-grpc';
|
||||
import { getPNIDByAPIAccessToken } from '@/database';
|
||||
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
|
||||
import { config } from '@/config-manager';
|
||||
import { Device } from '@/models/device';
|
||||
import type { GetUserDataResponse } from '@pretendonetwork/grpc/account/v2/get_user_data_rpc';
|
||||
import type { ExchangeTokenForUserDataRequest } from '@pretendonetwork/grpc/account/v2/exchange_token_for_user_data';
|
||||
import type { ExchangeTokenForUserDataRequest, ExchangeTokenForUserDataResponse } from '@pretendonetwork/grpc/account/v2/exchange_token_for_user_data_rpc';
|
||||
|
||||
export async function exchangeTokenForUserData(request: ExchangeTokenForUserDataRequest): Promise<GetUserDataResponse> {
|
||||
export async function exchangeTokenForUserData(request: ExchangeTokenForUserDataRequest): Promise<ExchangeTokenForUserDataResponse> {
|
||||
if (!request.token.trim()) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
|
||||
}
|
||||
@@ -17,20 +15,8 @@ export async function exchangeTokenForUserData(request: ExchangeTokenForUserData
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
deleted: pnid.deleted,
|
||||
deleted: pnid.deleted || pnid.marked_for_deletion,
|
||||
pid: pnid.pid,
|
||||
username: pnid.username,
|
||||
accessLevel: pnid.access_level,
|
||||
@@ -70,7 +56,6 @@ export async function exchangeTokenForUserData(request: ExchangeTokenForUserData
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
9
src/services/grpc/account/v2/get-ban.ts
Normal file
9
src/services/grpc/account/v2/get-ban.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
9
src/services/grpc/account/v2/get-device.ts
Normal file
9
src/services/grpc/account/v2/get-device.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
9
src/services/grpc/account/v2/get-nex-account.ts
Normal file
9
src/services/grpc/account/v2/get-nex-account.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
9
src/services/grpc/account/v2/get-pnid.ts
Normal file
9
src/services/grpc/account/v2/get-pnid.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
75
src/services/grpc/account/v2/get-pnids.ts
Normal file
75
src/services/grpc/account/v2/get-pnids.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { getPNIDByPID } from '@/database';
|
||||
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
|
||||
import { config } from '@/config-manager';
|
||||
import { Device } from '@/models/device';
|
||||
import type { GetPNIDsRequest, GetPNIDsResponse } from '@pretendonetwork/grpc/account/v2/get_pnids_rpc';
|
||||
|
||||
export async function getPNIDs(request: GetPNIDsRequest): Promise<GetPNIDsResponse> {
|
||||
const response: GetPNIDsResponse = { userData: [] };
|
||||
for (const pid of request.pid) {
|
||||
const pnid = await getPNIDByPID(pid);
|
||||
if (!pnid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
});
|
||||
|
||||
response.userData[pid] = {
|
||||
deleted: pnid.deleted || pnid.marked_for_deletion,
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
9
src/services/grpc/account/v2/get-server.ts
Normal file
9
src/services/grpc/account/v2/get-server.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export async function getUserData(request: GetUserDataRequest): Promise<GetUserD
|
||||
linked_pids: pnid.pid
|
||||
})).map((device) => {
|
||||
return {
|
||||
deviceId: device.device_id,
|
||||
model: device.get('model'), // ".model" gives the Mongoose model...
|
||||
serial: device.serial,
|
||||
linkedPids: device.linked_pids,
|
||||
@@ -28,7 +29,7 @@ export async function getUserData(request: GetUserDataRequest): Promise<GetUserD
|
||||
});
|
||||
|
||||
return {
|
||||
deleted: pnid.deleted,
|
||||
deleted: pnid.deleted || pnid.marked_for_deletion,
|
||||
pid: pnid.pid,
|
||||
username: pnid.username,
|
||||
accessLevel: pnid.access_level,
|
||||
|
||||
@@ -3,13 +3,76 @@ 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 { 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';
|
||||
|
||||
export const accountServiceImplementationV2 = {
|
||||
export const accountServiceImplementationV2: AccountServiceImplementation = {
|
||||
getUserData,
|
||||
getNEXPassword,
|
||||
getNEXData,
|
||||
updatePNIDPermissions,
|
||||
exchangeTokenForUserData,
|
||||
deleteAccount
|
||||
exchangeOAuthTokenForUserData,
|
||||
exchangeNEXTokenForUserData,
|
||||
exchangeIndependentServiceTokenForUserData,
|
||||
exchangePasswordResetTokenForUserData,
|
||||
validateIndependentServiceToken,
|
||||
deleteAccount,
|
||||
listDevices,
|
||||
getDevice,
|
||||
updateDevice,
|
||||
listNEXAccounts,
|
||||
getNEXAccount,
|
||||
updateNEXAccount,
|
||||
listServers,
|
||||
createServer,
|
||||
getServer,
|
||||
updateServer,
|
||||
deleteServer,
|
||||
listPNIDs,
|
||||
getPNID,
|
||||
getPNIDs,
|
||||
updatePNID,
|
||||
deletePNID,
|
||||
listAuditLogs,
|
||||
listAuditLogComments,
|
||||
createAuditLogComment,
|
||||
listBans,
|
||||
issueBan,
|
||||
getBan,
|
||||
updateBan,
|
||||
pardonBan,
|
||||
listBanComments,
|
||||
createBanComment
|
||||
};
|
||||
|
||||
9
src/services/grpc/account/v2/issue-ban.ts
Normal file
9
src/services/grpc/account/v2/issue-ban.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
9
src/services/grpc/account/v2/list-audit-log-comments.ts
Normal file
9
src/services/grpc/account/v2/list-audit-log-comments.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
9
src/services/grpc/account/v2/list-audit-logs.ts
Normal file
9
src/services/grpc/account/v2/list-audit-logs.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
9
src/services/grpc/account/v2/list-ban-comments.ts
Normal file
9
src/services/grpc/account/v2/list-ban-comments.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
9
src/services/grpc/account/v2/list-bans.ts
Normal file
9
src/services/grpc/account/v2/list-bans.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
9
src/services/grpc/account/v2/list-devices.ts
Normal file
9
src/services/grpc/account/v2/list-devices.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
9
src/services/grpc/account/v2/list-nex-accounts.ts
Normal file
9
src/services/grpc/account/v2/list-nex-accounts.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
9
src/services/grpc/account/v2/list-pnids.ts
Normal file
9
src/services/grpc/account/v2/list-pnids.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
9
src/services/grpc/account/v2/list-servers.ts
Normal file
9
src/services/grpc/account/v2/list-servers.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
9
src/services/grpc/account/v2/pardon-ban.ts
Normal file
9
src/services/grpc/account/v2/pardon-ban.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
9
src/services/grpc/account/v2/update-ban.ts
Normal file
9
src/services/grpc/account/v2/update-ban.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
9
src/services/grpc/account/v2/update-device.ts
Normal file
9
src/services/grpc/account/v2/update-device.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
9
src/services/grpc/account/v2/update-nex-account.ts
Normal file
9
src/services/grpc/account/v2/update-nex-account.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Status, ServerError } from 'nice-grpc';
|
||||
import { getPNIDByPID } from '@/database';
|
||||
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
|
||||
import type { UpdatePNIDPermissionsRequest } from '@pretendonetwork/grpc/account/v2/update_pnid_permissions';
|
||||
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
|
||||
import type { UpdatePNIDPermissionsRequest, UpdatePNIDPermissionsResponse } from '@pretendonetwork/grpc/account/v2/update_pnid_permissions_rpc';
|
||||
|
||||
export async function updatePNIDPermissions(request: UpdatePNIDPermissionsRequest): Promise<Empty> {
|
||||
export async function updatePNIDPermissions(request: UpdatePNIDPermissionsRequest): Promise<UpdatePNIDPermissionsResponse> {
|
||||
const pnid = await getPNIDByPID(request.pid);
|
||||
|
||||
if (!pnid) {
|
||||
|
||||
9
src/services/grpc/account/v2/update-pnid.ts
Normal file
9
src/services/grpc/account/v2/update-pnid.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
9
src/services/grpc/account/v2/update-server.ts
Normal file
9
src/services/grpc/account/v2/update-server.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export async function getUserData(_request: Empty, context: CallContext & Authen
|
||||
const pnid = context.pnid!;
|
||||
|
||||
return {
|
||||
deleted: pnid.deleted,
|
||||
deleted: pnid.deleted || pnid.marked_for_deletion,
|
||||
creationDate: pnid.creation_date,
|
||||
updatedDate: pnid.updated,
|
||||
pid: pnid.pid,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -52,35 +53,40 @@ export async function login(request: LoginRequest): Promise<DeepPartial<LoginRes
|
||||
}
|
||||
}
|
||||
|
||||
if (pnid.deleted) {
|
||||
if (pnid.deleted || pnid.marked_for_deletion) {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function updateUserData(_request: UpdateUserDataRequest, context: C
|
||||
// TODO - STUBBED, DO SOMETHING HERE
|
||||
|
||||
return {
|
||||
deleted: pnid.deleted,
|
||||
deleted: pnid.deleted || pnid.marked_for_deletion,
|
||||
creationDate: pnid.creation_date,
|
||||
updatedDate: pnid.updated,
|
||||
pid: pnid.pid,
|
||||
|
||||
@@ -17,8 +17,7 @@ export async function deleteAccount(request: DeleteAccountRequest): Promise<Dele
|
||||
try {
|
||||
const email = pnid.email.address;
|
||||
|
||||
await pnid.scrub();
|
||||
await pnid.save();
|
||||
await pnid.markForDeletion();
|
||||
|
||||
await sendPNIDDeletedEmail(email, pnid.username);
|
||||
} catch (error) {
|
||||
@@ -26,6 +25,6 @@ export async function deleteAccount(request: DeleteAccountRequest): Promise<Dele
|
||||
}
|
||||
|
||||
return {
|
||||
hasDeleted: pnid.deleted
|
||||
hasDeleted: pnid.deleted || pnid.marked_for_deletion
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export async function getUserData(_request: Empty, context: CallContext & Authen
|
||||
const pnid = context.pnid!;
|
||||
|
||||
return {
|
||||
deleted: pnid.deleted,
|
||||
deleted: pnid.deleted || pnid.marked_for_deletion,
|
||||
creationDate: pnid.creation_date,
|
||||
updatedDate: pnid.updated,
|
||||
pid: pnid.pid,
|
||||
|
||||
@@ -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';
|
||||
@@ -52,43 +53,45 @@ export async function login(request: LoginRequest): Promise<DeepPartial<LoginRes
|
||||
}
|
||||
}
|
||||
|
||||
if (pnid.deleted) {
|
||||
if (pnid.deleted || pnid.marked_for_deletion) {
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function updateUserData(_request: UpdateUserDataRequest, context: C
|
||||
// TODO - STUBBED, DO SOMETHING HERE
|
||||
|
||||
return {
|
||||
deleted: pnid.deleted,
|
||||
deleted: pnid.deleted || pnid.marked_for_deletion,
|
||||
creationDate: pnid.creation_date,
|
||||
updatedDate: pnid.updated,
|
||||
pid: pnid.pid,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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 { nascRatelimit } from '@/middleware/ratelimit';
|
||||
import type { NASCACRequestParams, NASCLoginACRequestParams, NASCServiceTokenACRequestParams } from '@/types/services/nasc/ac-request-params';
|
||||
import type { HydratedServerDocument } from '@/types/mongoose/server';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -13,8 +17,8 @@ const router = express.Router();
|
||||
* Replacement for: https://nasc.nintendowifi.net/ac
|
||||
* Description: Gets a NEX server address and token
|
||||
*/
|
||||
router.post('/', async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
const requestParams: NASCRequestParams = request.body;
|
||||
router.post('/', nascRatelimit, async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
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 +65,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())
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
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';
|
||||
import { loginRatelimit } from '@/middleware/ratelimit';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -17,7 +18,7 @@ const router = express.Router();
|
||||
* Replacement for: https://account.nintendo.net/v1/api/oauth20/access_token/generate
|
||||
* Description: Generates an access token for a user
|
||||
*/
|
||||
router.post('/access_token/generate', deviceCertificateMiddleware, consoleStatusVerificationMiddleware, async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
router.post('/access_token/generate', loginRatelimit, deviceCertificateMiddleware, consoleStatusVerificationMiddleware, async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
const grantType = request.body.grant_type;
|
||||
const username = request.body.user_id;
|
||||
const password = request.body.password;
|
||||
@@ -128,7 +129,7 @@ router.post('/access_token/generate', deviceCertificateMiddleware, consoleStatus
|
||||
}
|
||||
}
|
||||
|
||||
if (pnid.deleted) {
|
||||
if (pnid.deleted || pnid.marked_for_deletion) {
|
||||
// * 0112 is the "account deleted" error, but unsure if this unlinks the PNID from the user?
|
||||
// * 0143 is the "The link to this Nintendo Network ID has been temporarliy removed" error,
|
||||
// * maybe that is a better error to use here?
|
||||
@@ -167,25 +168,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
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import xmlbuilder from 'xmlbuilder';
|
||||
import bcrypt from 'bcrypt';
|
||||
import moment from 'moment';
|
||||
import deviceCertificateMiddleware from '@/middleware/device-certificate';
|
||||
import ratelimit from '@/middleware/ratelimit';
|
||||
import { deviceRatelimit } from '@/middleware/ratelimit';
|
||||
import { connection as databaseConnection, doesPNIDExist, getPNIDProfileJSONByPID } from '@/database';
|
||||
import { getAgeFromDate, getValueFromHeaders, nintendoPasswordHash, sendConfirmationEmail, sendPNIDDeletedEmail } from '@/util';
|
||||
import IP2LocationManager from '@/ip2location';
|
||||
@@ -49,7 +49,7 @@ router.get('/:username', async (request: express.Request, response: express.Resp
|
||||
* Replacement for: https://account.nintendo.net/v1/api/people
|
||||
* Description: Registers a new NNID
|
||||
*/
|
||||
router.post('/', ratelimit, deviceCertificateMiddleware, async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
router.post('/', deviceRatelimit, deviceCertificateMiddleware, async (request: express.Request, response: express.Response): Promise<void> => {
|
||||
if (!request.certificate || !request.certificate.valid) {
|
||||
// TODO - Change this to a different error
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
@@ -89,6 +89,25 @@ router.post('/', ratelimit, deviceCertificateMiddleware, async (request: express
|
||||
}
|
||||
}
|
||||
|
||||
if (age < 13) {
|
||||
// * Wii U firmware 5.5.6 changed NNID setup to block setup of new accounts if the users age is
|
||||
// * under 13, telling parents that they MUST call Nintendo to create the account, and we trusted
|
||||
// * that users would be on these firmwares. Lower firmwares won't have this though, and will use
|
||||
// * the old "COPPA approval" system
|
||||
// *
|
||||
// * Just block it all the time though, we don't want to deal with this headache
|
||||
response.status(400).send(xmlbuilder.create({
|
||||
errors: {
|
||||
error: {
|
||||
code: '0114',
|
||||
message: 'COPPA approval is not complete'
|
||||
}
|
||||
}
|
||||
}).end());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const userExists = await doesPNIDExist(person.user_id);
|
||||
|
||||
if (userExists) {
|
||||
@@ -561,8 +580,7 @@ router.post('/@me/deletion', async (request: express.Request, response: express.
|
||||
|
||||
const email = pnid.email.address;
|
||||
|
||||
await pnid.scrub();
|
||||
await pnid.save();
|
||||
await pnid.markForDeletion();
|
||||
|
||||
try {
|
||||
await sendPNIDDeletedEmail(email, pnid.username);
|
||||
@@ -632,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;
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -1983,7 +1983,7 @@
|
||||
{
|
||||
"area": "America/Nassau",
|
||||
"language": "en",
|
||||
"name": "Eastern Time (US & Canada)",
|
||||
"name": "Eastern Time (US & Canada)",
|
||||
"utc_offset": "-18000",
|
||||
"order": "0"
|
||||
}
|
||||
@@ -2171,14 +2171,14 @@
|
||||
{
|
||||
"area": "America/Dawson",
|
||||
"language": "en",
|
||||
"name": "Pacific Time (US & Canada)",
|
||||
"name": "Pacific Time (US & Canada)",
|
||||
"utc_offset": "-28800",
|
||||
"order": "1"
|
||||
},
|
||||
{
|
||||
"area": "America/Cambridge_Bay",
|
||||
"language": "en",
|
||||
"name": "Mountain Time (US & Canada)",
|
||||
"name": "Mountain Time (US & Canada)",
|
||||
"utc_offset": "-25200",
|
||||
"order": "2"
|
||||
},
|
||||
@@ -2192,7 +2192,7 @@
|
||||
{
|
||||
"area": "America/Rainy_River",
|
||||
"language": "en",
|
||||
"name": "Central Time (US & Canada)",
|
||||
"name": "Central Time (US & Canada)",
|
||||
"utc_offset": "-21600",
|
||||
"order": "4"
|
||||
},
|
||||
@@ -2213,7 +2213,7 @@
|
||||
{
|
||||
"area": "America/Iqaluit",
|
||||
"language": "en",
|
||||
"name": "Eastern Time (US & Canada)",
|
||||
"name": "Eastern Time (US & Canada)",
|
||||
"utc_offset": "-18000",
|
||||
"order": "7"
|
||||
},
|
||||
@@ -5038,7 +5038,7 @@
|
||||
{
|
||||
"area": "America/Grand_Turk",
|
||||
"language": "en",
|
||||
"name": "Eastern Time (US & Canada)",
|
||||
"name": "Eastern Time (US & Canada)",
|
||||
"utc_offset": "-14400",
|
||||
"order": "3"
|
||||
},
|
||||
@@ -12519,7 +12519,7 @@
|
||||
{
|
||||
"area": "America/Grand_Turk",
|
||||
"language": "en",
|
||||
"name": "Eastern Time (US & Canada)",
|
||||
"name": "Eastern Time (US & Canada)",
|
||||
"utc_offset": "-14400",
|
||||
"order": "0"
|
||||
}
|
||||
@@ -13049,14 +13049,14 @@
|
||||
{
|
||||
"area": "America/Los_Angeles",
|
||||
"language": "en",
|
||||
"name": "Pacific Time (US & Canada)",
|
||||
"name": "Pacific Time (US & Canada)",
|
||||
"utc_offset": "-28800",
|
||||
"order": "5"
|
||||
},
|
||||
{
|
||||
"area": "America/Boise",
|
||||
"language": "en",
|
||||
"name": "Mountain Time (US & Canada)",
|
||||
"name": "Mountain Time (US & Canada)",
|
||||
"utc_offset": "-25200",
|
||||
"order": "6"
|
||||
},
|
||||
@@ -13070,7 +13070,7 @@
|
||||
{
|
||||
"area": "America/Chicago",
|
||||
"language": "en",
|
||||
"name": "Central Time (US & Canada)",
|
||||
"name": "Central Time (US & Canada)",
|
||||
"utc_offset": "-21600",
|
||||
"order": "8"
|
||||
},
|
||||
@@ -13091,7 +13091,7 @@
|
||||
{
|
||||
"area": "America/New_York",
|
||||
"language": "en",
|
||||
"name": "Eastern Time (US & Canada)",
|
||||
"name": "Eastern Time (US & Canada)",
|
||||
"utc_offset": "-18000",
|
||||
"order": "11"
|
||||
},
|
||||
|
||||
6
src/types/common/service-token-options.ts
Normal file
6
src/types/common/service-token-options.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface ServiceTokenOptions {
|
||||
pid: number;
|
||||
title_id: string;
|
||||
issued: Date;
|
||||
expires: Date;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import type { PNIDPermissionFlag } from '@/types/common/permission-flags';
|
||||
|
||||
export interface IPNID {
|
||||
deleted: boolean;
|
||||
marked_for_deletion: boolean;
|
||||
hard_delete_time: Date;
|
||||
permissions: bigint;
|
||||
access_level: number;
|
||||
server_access_level: string;
|
||||
@@ -81,7 +83,9 @@ export interface IPNIDMethods {
|
||||
generateEmailValidationToken(): Promise<void>;
|
||||
updateMii(mii: { name: string; primary: string; data: string }): Promise<void>;
|
||||
generateMiiImages(): Promise<void>;
|
||||
markForDeletion(): void;
|
||||
scrub(): Promise<void>;
|
||||
removeAllTokens(): Promise<void>;
|
||||
hasPermission(flag: PNIDPermissionFlag): boolean;
|
||||
addPermission(flag: PNIDPermissionFlag): void;
|
||||
clearPermission(flag: PNIDPermissionFlag): void;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import type { DiscordConnectionData } from '@/types/services/api/discord-connection-data';
|
||||
|
||||
// TODO - This will be a union of all ConnectionData types when more connections are added
|
||||
export type ConnectionData = DiscordConnectionData;
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface ConnectionResponse {
|
||||
app: string;
|
||||
status: number;
|
||||
error?: string;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface DiscordConnectionData {
|
||||
id: string;
|
||||
}
|
||||
216
src/types/services/nasc/ac-request-params.ts
Normal file
216
src/types/services/nasc/ac-request-params.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
177
src/util.ts
177
src/util.ts
@@ -2,21 +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 { IncomingHttpHeaders } from 'node:http';
|
||||
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;
|
||||
|
||||
@@ -56,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 {
|
||||
@@ -248,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,
|
||||
@@ -278,14 +204,24 @@ export async function sendForgotPasswordEmail(pnid: mongoose.HydratedDocument<IP
|
||||
}
|
||||
|
||||
export async function sendPNIDDeletedEmail(emailAddress: string, username: string): Promise<void> {
|
||||
const deletionDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toLocaleString('en-US', {
|
||||
timeZone: 'UTC',
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
const email = new CreateEmail()
|
||||
.addHeader('Dear {{pnid}},', { pnid: username })
|
||||
.addParagraph('your PNID has successfully been deleted.')
|
||||
.addParagraph('If you had a tier subscription, a separate cancellation email will be sent. If you do not receive this cancellation email, or you are still being charged for your subscription, please contact <b>@jonbarrow</b> on our [Discord server](https://discord.pretendo.network/).');
|
||||
.addHeader('Dear {{pnid}}.', { pnid: username })
|
||||
.addParagraph('Your PNID has been scheduled for deletion.')
|
||||
.addParagraph(`Your account and related data will be permanently deleted in 7 days (${deletionDate}). Note that this will not free the username associated with the account for use in future accounts.`)
|
||||
.addParagraph('You may restore your account at any time before the deletion date. To do so, email [restore-account@pretendo.network](mailto:restore-account@pretendo.network?subject=Requesting%20account%20restore&body=Please%20restore%20my%20account) from the email address used to register, with the subject "Requesting account restore" and your PNID username in the body. Requests must be submitted more than 24 hours before the deletion date, as after this point your data may be unrecoverable. For additional help, visit our [Forum](https://forum.pretendo.network/) or [Discord server](https://discord.pretendo.network/).')
|
||||
.addParagraph('If you have or have had an active tier subscription, your associated Stripe data (including payment info and invoices) will be permanently deleted on the deletion date and cannot be restored.')
|
||||
.addParagraph('No new charges will be made during the grace period, even if a renewal would normally occur. If your account is restored with an active subscription, you may need to resubscribe. If you notice unexpected charges during the grace period, please contact us via our [Forum](https://forum.pretendo.network/) or [Discord server](https://discord.pretendo.network/) before the deletion date.');
|
||||
|
||||
const options = {
|
||||
to: emailAddress,
|
||||
subject: '[Pretendo Network] PNID Deleted',
|
||||
subject: '[Pretendo Network] PNID Deletion',
|
||||
email
|
||||
};
|
||||
|
||||
@@ -392,3 +328,24 @@ export function getAgeFromDate(dateString: string): number {
|
||||
|
||||
return age;
|
||||
}
|
||||
|
||||
export async function setupScheduledTasks(): Promise<void> {
|
||||
scheduledTask('0 2 * * *', 'check-account-deletions', checkMarkedDeletions);
|
||||
}
|
||||
|
||||
function scheduledTask(schedule: string, name: string, fn: () => void | Promise<void>): void {
|
||||
CronJob.from({
|
||||
cronTime: schedule,
|
||||
onTick: async () => {
|
||||
try {
|
||||
const result = fn();
|
||||
await result;
|
||||
} catch (err) {
|
||||
LOG_ERROR(`Error in schedule ${name}: ${err}`);
|
||||
}
|
||||
},
|
||||
start: true
|
||||
});
|
||||
|
||||
LOG_ERROR(`Added schedule ${name} for ${schedule}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user