feat(nnas/api): add Mississippi check at registration

This commit is contained in:
Jonathan Barrow 2025-08-26 15:05:33 -04:00
parent 1a3011e9b4
commit a088c29822
No known key found for this signature in database
GPG Key ID: 2A7DAA6DED5A77E5
8 changed files with 287 additions and 27 deletions

View File

@ -2,5 +2,7 @@
Replacement for several account-based services used by the WiiU and 3DS. It replaces the NNID api as well as NASC for the 3DS. It also contains a dedicated PNID api service for getting details of PNIDs outside of the consoles (used by the website)
Uses the [IP2Location LITE database](https://lite.ip2location.com) for IP geolocation.
## Setup
See [SETUP.md](SETUP.md) for how to self host

View File

@ -64,28 +64,29 @@ The Pretendo Network website uses this server as an API for querying user inform
Configurations are loaded through environment variables. `.env` files are supported. All configuration options will be gone over, both required and optional. There also exists an example `.env` file
| Name | Description | Optional |
|-----------------------------------------------|--------------------------------------------------------------------------------------------------|----------|
| `PN_ACT_CONFIG_HTTP_PORT` | The HTTP port the server listens on | No |
| `PN_ACT_CONFIG_MONGO_CONNECTION_STRING` | MongoDB connection string | No |
| `PN_ACT_CONFIG_MONGOOSE_CONNECT_OPTIONS_PATH` | Path to a `.json` file containing Mongoose connection options | Yes |
| `PN_ACT_CONFIG_REDIS_URL` | Redis URL | Yes |
| `PN_ACT_CONFIG_EMAIL_SES_REGION` | Amazon SES Region | Yes |
| `PN_ACT_CONFIG_EMAIL_SES_ACCESS_KEY` | Amazon SES Access Key | Yes |
| `PN_ACT_CONFIG_EMAIL_SES_SECRET_KEY` | Amazon SES Access Secret | Yes |
| `PN_ACT_CONFIG_EMAIL_FROM` | Email "from" address | Yes |
| `PN_ACT_CONFIG_S3_ENDPOINT` | s3 server endpoint | Yes |
| `PN_ACT_CONFIG_S3_ACCESS_KEY` | s3 secret key | Yes |
| `PN_ACT_CONFIG_S3_ACCESS_SECRET` | s3 secret | Yes |
| `PN_ACT_CONFIG_HCAPTCHA_SECRET` | hCaptcha secret (in the form `0x...`) | Yes |
| `PN_ACT_CONFIG_CDN_SUBDOMAIN` | Subdomain used to serve CDN contents if s3 is disabled | Yes |
| `PN_ACT_CONFIG_CDN_DISK_PATH` | File system path used to store CDN contents if s3 is disabled | Yes |
| `PN_ACT_CONFIG_CDN_BASE_URL` | URL for serving CDN contents (usually the same as s3 endpoint) | No |
| `PN_ACT_CONFIG_WEBSITE_BASE` | Website URL | Yes |
| `PN_ACT_CONFIG_AES_KEY` | AES-256 key used for encrypting tokens | No |
| `PN_ACT_CONFIG_DATASTORE_SIGNATURE_SECRET` | HMAC secret key (16 bytes in hex format) used to sign uploaded DataStore files | No |
| `PN_ACT_CONFIG_GRPC_MASTER_API_KEY_ACCOUNT` | Master API key to interact with the account gRPC service | No |
| `PN_ACT_CONFIG_GRPC_MASTER_API_KEY_API` | Master API key to interact with the API gRPC service | No |
| `PN_ACT_CONFIG_GRPC_PORT` | gRPC server port | No |
| `PN_ACT_CONFIG_STRIPE_SECRET_KEY` | Stripe API key. Used to cancel subscriptions when scrubbing PNIDs | Yes |
| `PN_ACT_CONFIG_SERVER_ENVIRONMENT` | Server environment. Currently only used by the Wii U Account Settings app. `prod`/`test`/`dev` | Yes |
| Name | Description | Optional |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------- | -------- |
| `PN_ACT_CONFIG_HTTP_PORT` | The HTTP port the server listens on | No |
| `PN_ACT_CONFIG_IP2LOCATION_TOKEN` | Download token for https://lite.ip2location.com. Used to download the local IP databases | No |
| `PN_ACT_CONFIG_MONGO_CONNECTION_STRING` | MongoDB connection string | No |
| `PN_ACT_CONFIG_MONGOOSE_CONNECT_OPTIONS_PATH` | Path to a `.json` file containing Mongoose connection options | Yes |
| `PN_ACT_CONFIG_REDIS_URL` | Redis URL | Yes |
| `PN_ACT_CONFIG_EMAIL_SES_REGION` | Amazon SES Region | Yes |
| `PN_ACT_CONFIG_EMAIL_SES_ACCESS_KEY` | Amazon SES Access Key | Yes |
| `PN_ACT_CONFIG_EMAIL_SES_SECRET_KEY` | Amazon SES Access Secret | Yes |
| `PN_ACT_CONFIG_EMAIL_FROM` | Email "from" address | Yes |
| `PN_ACT_CONFIG_S3_ENDPOINT` | s3 server endpoint | Yes |
| `PN_ACT_CONFIG_S3_ACCESS_KEY` | s3 secret key | Yes |
| `PN_ACT_CONFIG_S3_ACCESS_SECRET` | s3 secret | Yes |
| `PN_ACT_CONFIG_HCAPTCHA_SECRET` | hCaptcha secret (in the form `0x...`) | Yes |
| `PN_ACT_CONFIG_CDN_SUBDOMAIN` | Subdomain used to serve CDN contents if s3 is disabled | Yes |
| `PN_ACT_CONFIG_CDN_DISK_PATH` | File system path used to store CDN contents if s3 is disabled | Yes |
| `PN_ACT_CONFIG_CDN_BASE_URL` | URL for serving CDN contents (usually the same as s3 endpoint) | No |
| `PN_ACT_CONFIG_WEBSITE_BASE` | Website URL | Yes |
| `PN_ACT_CONFIG_AES_KEY` | AES-256 key used for encrypting tokens | No |
| `PN_ACT_CONFIG_DATASTORE_SIGNATURE_SECRET` | HMAC secret key (16 bytes in hex format) used to sign uploaded DataStore files | No |
| `PN_ACT_CONFIG_GRPC_MASTER_API_KEY_ACCOUNT` | Master API key to interact with the account gRPC service | No |
| `PN_ACT_CONFIG_GRPC_MASTER_API_KEY_API` | Master API key to interact with the API gRPC service | No |
| `PN_ACT_CONFIG_GRPC_PORT` | gRPC server port | No |
| `PN_ACT_CONFIG_STRIPE_SECRET_KEY` | Stripe API key. Used to cancel subscriptions when scrubbing PNIDs | Yes |
| `PN_ACT_CONFIG_SERVER_ENVIRONMENT` | Server environment. Currently only used by the Wii U Account Settings app. `prod`/`test`/`dev` | Yes |

83
package-lock.json generated
View File

@ -28,6 +28,7 @@
"got": "^11.8.2",
"hcaptcha": "^0.1.0",
"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",
@ -42,6 +43,7 @@
"stripe": "^12.3.0",
"tga": "^1.0.4",
"typescript-is": "^0.19.0",
"unzipper": "^0.12.3",
"validator": "^13.7.0",
"xmlbuilder": "^13.0.2",
"xmlbuilder2": "0.0.4",
@ -4198,6 +4200,12 @@
"integrity": "sha512-x1yGnmXvFg6e3DiyRztElbcn1bsCTFSoM/ncAzY62uE0JdTl5xlKJd0ooqLYoPbhdsnpehSIQrdIvclcZJYwiA==",
"license": "MIT"
},
"node_modules/bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"license": "MIT"
},
"node_modules/bmp-js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
@ -4795,6 +4803,18 @@
"node": ">= 8"
}
},
"node_modules/csv-parser": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.0.tgz",
"integrity": "sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA==",
"license": "MIT",
"bin": {
"csv-parser": "bin/csv-parser"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/d": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
@ -7036,6 +7056,15 @@
"node": ">= 12"
}
},
"node_modules/ip2location-nodejs": {
"version": "9.6.3",
"resolved": "https://registry.npmjs.org/ip2location-nodejs/-/ip2location-nodejs-9.6.3.tgz",
"integrity": "sha512-npgq6Dwk0G53GKbxUCzZvosr0KPfreufohFKSzM7vAGtbmpuO2KwULQb5AxkCtlBIZ7xFShri7R1iVUbe7BKTw==",
"license": "MIT",
"dependencies": {
"csv-parser": "^3.0.0"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -8345,6 +8374,12 @@
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"license": "MIT"
},
"node_modules/node-rsa": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-1.1.1.tgz",
@ -10494,6 +10529,54 @@
"node": ">=8"
}
},
"node_modules/unzipper": {
"version": "0.12.3",
"resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz",
"integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==",
"license": "MIT",
"dependencies": {
"bluebird": "~3.7.2",
"duplexer2": "~0.1.4",
"fs-extra": "^11.2.0",
"graceful-fs": "^4.2.2",
"node-int64": "^0.4.0"
}
},
"node_modules/unzipper/node_modules/fs-extra": {
"version": "11.3.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz",
"integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/unzipper/node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/unzipper/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",

View File

@ -6,7 +6,7 @@
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"build": "npm run lint && npm run clean && npx tsc && npx tsc-alias && npm run copy-static",
"build": "npm run lint && npm run clean && npx tsc && npx tsc-alias && npm run copy-static && node ./scripts/download-ip2location-databases.js",
"clean": "rimraf ./dist",
"copy-static": "copyfiles -e \"src/**/*.ts\" -u 1 \"src/**/*\" dist",
"start": "node .",
@ -43,6 +43,7 @@
"got": "^11.8.2",
"hcaptcha": "^0.1.0",
"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",
@ -57,6 +58,7 @@
"stripe": "^12.3.0",
"tga": "^1.0.4",
"typescript-is": "^0.19.0",
"unzipper": "^0.12.3",
"validator": "^13.7.0",
"xmlbuilder": "^13.0.2",
"xmlbuilder2": "0.0.4",
@ -85,4 +87,4 @@
"ndarray": "^1.0.19",
"typescript": "^4.9.5"
}
}
}

View File

@ -0,0 +1,82 @@
const { Readable } = require('node:stream');
const fs = require('node:fs');
const path = require('node:path');
const unzipper = require('unzipper');
require('dotenv').config();
// * unzipper wants to use the "request" module, which is deprecated and insecure.
// * Just wrap native fetch to avoid another dependancy here
// TODO - This is kinda ugly, can this be better?
function request(options) {
const url = typeof options === 'string' ? options : options.url;
const headers = options.headers || {};
const stream = new Readable({
read() {} // * Noop. Push data manually
});
fetch(url, { headers }).then((response) => {
if (!response.ok) {
const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
error.statusCode = response.status;
stream.emit('error', error);
return;
}
stream.emit('response', {
statusCode: response.status,
headers: Object.fromEntries(response.headers.entries())
});
const reader = response.body.getReader();
function pump() {
reader.read().then(({ done, value }) => {
if (done) {
stream.push(null);
} else {
stream.push(Buffer.from(value));
pump();
}
}).catch((error) => {
stream.emit('error', error);
});
}
pump();
}).catch((error) => {
stream.emit('error', error);
});
stream.abort = function () {
stream.destroy();
};
return stream;
}
const databases = {
DB3LITEBIN: {
file_name: 'IP2LOCATION-LITE-DB3.BIN',
save_path: path.join(__dirname, '..', 'dist', 'IP2LOCATION-LITE-DB3.IPV4.BIN')
},
DB3LITEBINIPV6: {
file_name: 'IP2LOCATION-LITE-DB3.IPV6.BIN',
save_path: path.join(__dirname, '..', 'dist', 'IP2LOCATION-LITE-DB3.IPV6.BIN')
}
};
async function main() {
for (const name in databases) {
const database = databases[name];
const directory = await unzipper.Open.url(request, `https://www.ip2location.com/download/?token=${process.env.PN_ACT_CONFIG_IP2LOCATION_TOKEN}&file=${name}`);
const file = directory.files.find(file => file.path === database.file_name);
const content = await file.buffer();
fs.writeFileSync(database.save_path, content);
}
}
main();

38
src/ip2location.ts Normal file
View File

@ -0,0 +1,38 @@
import path from 'node:path';
import net from 'node:net';
import * as IP2Location from 'ip2location-nodejs';
class IP2LocationManager {
private ipv4: IP2Location.IP2Location;
private ipv6: IP2Location.IP2Location;
constructor() {
this.ipv4 = new IP2Location.IP2Location();
this.ipv6 = new IP2Location.IP2Location();
this.ipv4.open(path.join(__dirname, 'IP2LOCATION-LITE-DB3.IPV4.BIN'));
this.ipv6.open(path.join(__dirname, 'IP2LOCATION-LITE-DB3.IPV6.BIN'));
}
public lookup(ip: string): { country: string; region: string } | null {
const ipVersion = net.isIP(ip);
let result;
if (ipVersion === 4) {
result = this.ipv4.getAll(ip);
} else if (ipVersion === 6) {
result = this.ipv6.getAll(ip);
} else {
return null;
}
return {
country: result.countryShort,
region: result.region
};
}
}
const manager = new IP2LocationManager();
export default manager;

View File

@ -7,6 +7,7 @@ import hcaptcha from 'hcaptcha';
import Mii from 'mii-js';
import { doesPNIDExist, connection as databaseConnection } from '@/database';
import { nintendoPasswordHash, sendConfirmationEmail, generateToken } 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';
@ -37,6 +38,8 @@ const DEFAULT_MII_DATA = Buffer.from('AwAAQOlVognnx0GC2/uogAOzuI0n2QAAAEBEAGUAZg
* Description: Creates a new user PNID
*/
router.post('/', 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();
const username = request.body.username?.trim();
const miiName = request.body.mii_name?.trim();
@ -68,6 +71,27 @@ router.post('/', async (request: express.Request, response: express.Response): P
}
}
// TODO - This is kinda ugly
const birthdate = new Date(birthday);
const today = new Date();
const eighteenthBirthday = new Date(birthdate);
eighteenthBirthday.setFullYear(birthdate.getFullYear() + 18);
if (today < eighteenthBirthday) {
// TODO - Enable `CF-IPCountry` in Cloudflare and only use IP2Location as a fallback
const location = IP2LocationManager.lookup(clientIP);
if (location?.country === 'US' && location?.region === 'Mississippi') {
// * See https://bsky.social/about/blog/08-22-2025-mississippi-hb1126 for details
response.status(403).json({
app: 'api',
status: 403,
error: 'Mississippi law prevents us from collecting any data from any users under the age of 18 without extreme parental verification methods.' // TODO - Expand on this and translate it? this will be shown on the website
});
return;
}
}
if (!email || email === '') {
response.status(400).json({
app: 'api',

View File

@ -7,6 +7,7 @@ import deviceCertificateMiddleware from '@/middleware/device-certificate';
import ratelimit from '@/middleware/ratelimit';
import { connection as databaseConnection, doesPNIDExist, getPNIDProfileJSONByPID } from '@/database';
import { getValueFromHeaders, nintendoPasswordHash, sendConfirmationEmail, sendPNIDDeletedEmail } from '@/util';
import IP2LocationManager from '@/ip2location';
import { PNID } from '@/models/pnid';
import { NEXAccount } from '@/models/nex-account';
import { LOG_ERROR } from '@/logger';
@ -64,6 +65,33 @@ router.post('/', ratelimit, deviceCertificateMiddleware, async (request: express
const person: Person = request.body.person;
// TODO - This is kinda ugly
const birthdate = new Date(person.birth_date);
const today = new Date();
const eighteenthBirthday = new Date(birthdate);
eighteenthBirthday.setFullYear(birthdate.getFullYear() + 18);
if (today < eighteenthBirthday) {
// TODO - Enable `CF-IPCountry` in Cloudflare and only use IP2Location as a fallback
const ip = (request.headers['cf-connecting-ip'] || request.headers['x-forwarded-for'] || request.ip) as string | undefined;
if (ip) {
const location = IP2LocationManager.lookup(ip);
if (location?.country === 'US' && location?.region === 'Mississippi') {
// * See https://bsky.social/about/blog/08-22-2025-mississippi-hb1126 for details
response.status(403).send(xmlbuilder.create({
errors: {
error: {
code: '1228', // TODO - This is made up because 228 is a Mississippi area code /shrug
message: 'Mississippi law prevents us from collecting any data from any users under the age of 18 without extreme parental verification methods.' // TODO - Translate this? It wont show to end users so maybe not though
}
}
}).end());
return;
}
}
}
const userExists = await doesPNIDExist(person.user_id);
if (userExists) {