chore: centralize NNID username/password rule checks

removes a ton of duplicated code between gRPC, API, and NNAS
This commit is contained in:
Jonathan Barrow
2026-08-23 17:38:43 -04:00
parent 21567faab4
commit 9c7900aacb
4 changed files with 134 additions and 158 deletions

View File

@@ -6,7 +6,22 @@ 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 } from '@/util';
import {
isValidBirthday,
getAgeFromDate,
checkNNIDUsernameMinLength,
checkNNIDUsernameMaxLength,
checkNNIDUsernameValidCharacters,
checkNNIDUsernamePunctuationStart,
checkNNIDUsernamePunctuationEnd,
checkNNIDUsernameDuplicate,
checkNNIDPasswordMinLength,
checkNNIDPasswordMaxLength,
checkNNIDPasswordAtLeast2CharacterGroups,
checkNNIDPasswordRepeatCharacters,
nintendoPasswordHash,
sendConfirmationEmail
} from '@/util';
import IP2LocationManager from '@/ip2location';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
@@ -21,17 +36,6 @@ import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
const router = express.Router();
const PNID_VALID_CHARACTERS_REGEX = /^[\w\-.]*$/;
const PNID_PUNCTUATION_START_REGEX = /^[_\-.]/;
const PNID_PUNCTUATION_END_REGEX = /[_\-.]$/;
const PNID_PUNCTUATION_DUPLICATE_REGEX = /[_\-.]{2,}/;
// * This sucks
const PASSWORD_WORD_OR_NUMBER_REGEX = /(?=.*[a-zA-Z])(?=.*\d).*/;
const PASSWORD_WORD_OR_PUNCTUATION_REGEX = /(?=.*[a-zA-Z])(?=.*[_\-.]).*/;
const PASSWORD_NUMBER_OR_PUNCTUATION_REGEX = /(?=.*\d)(?=.*[_\-.]).*/;
const PASSWORD_REPEATED_CHARACTER_REGEX = /(.)\1\1/;
const DEFAULT_MII_DATA = Buffer.from('AwAAQOlVognnx0GC2/uogAOzuI0n2QAAAEBEAGUAZgBhAHUAbAB0AAAAAAAAAEBAAAAhAQJoRBgmNEYUgRIXaA0AACkAUkhQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGm9', 'base64');
/**
@@ -166,7 +170,7 @@ router.post('/', webRegisterRatelimit, async (request: express.Request, response
return;
}
if (username.length < 6) {
if (!checkNNIDUsernameMinLength(username)) {
response.status(400).json({
app: 'api',
status: 400,
@@ -176,7 +180,7 @@ router.post('/', webRegisterRatelimit, async (request: express.Request, response
return;
}
if (username.length > 16) {
if (!checkNNIDUsernameMaxLength(username)) {
response.status(400).json({
app: 'api',
status: 400,
@@ -186,8 +190,7 @@ router.post('/', webRegisterRatelimit, async (request: express.Request, response
return;
}
if (!PNID_VALID_CHARACTERS_REGEX.test(username)) {
console.log(Buffer.from(username));
if (!checkNNIDUsernameValidCharacters(username)) {
response.status(400).json({
app: 'api',
status: 400,
@@ -197,7 +200,7 @@ router.post('/', webRegisterRatelimit, async (request: express.Request, response
return;
}
if (PNID_PUNCTUATION_START_REGEX.test(username)) {
if (!checkNNIDUsernamePunctuationStart(username)) {
response.status(400).json({
app: 'api',
status: 400,
@@ -207,7 +210,7 @@ router.post('/', webRegisterRatelimit, async (request: express.Request, response
return;
}
if (PNID_PUNCTUATION_END_REGEX.test(username)) {
if (!checkNNIDUsernamePunctuationEnd(username)) {
response.status(400).json({
app: 'api',
status: 400,
@@ -217,7 +220,7 @@ router.post('/', webRegisterRatelimit, async (request: express.Request, response
return;
}
if (PNID_PUNCTUATION_DUPLICATE_REGEX.test(username)) {
if (!checkNNIDUsernameDuplicate(username)) {
response.status(400).json({
app: 'api',
status: 400,
@@ -259,7 +262,7 @@ router.post('/', webRegisterRatelimit, async (request: express.Request, response
return;
}
if (password.length < 6) {
if (!checkNNIDPasswordMinLength(password)) {
response.status(400).json({
app: 'api',
status: 400,
@@ -269,7 +272,7 @@ router.post('/', webRegisterRatelimit, async (request: express.Request, response
return;
}
if (password.length > 16) {
if (!checkNNIDPasswordMaxLength(password)) {
response.status(400).json({
app: 'api',
status: 400,
@@ -289,7 +292,7 @@ router.post('/', webRegisterRatelimit, async (request: express.Request, response
return;
}
if (!PASSWORD_WORD_OR_NUMBER_REGEX.test(password) && !PASSWORD_WORD_OR_PUNCTUATION_REGEX.test(password) && !PASSWORD_NUMBER_OR_PUNCTUATION_REGEX.test(password)) {
if (!checkNNIDPasswordAtLeast2CharacterGroups(password)) {
response.status(400).json({
app: 'api',
status: 400,
@@ -299,7 +302,7 @@ router.post('/', webRegisterRatelimit, async (request: express.Request, response
return;
}
if (PASSWORD_REPEATED_CHARACTER_REGEX.test(password)) {
if (!checkNNIDPasswordRepeatCharacters(password)) {
response.status(400).json({
app: 'api',
status: 400,

View File

@@ -6,7 +6,20 @@ import moment from 'moment';
import hcaptcha from 'hcaptcha';
import Mii from 'mii-js';
import { doesPNIDExist, connection as databaseConnection } from '@/database';
import { nintendoPasswordHash, sendConfirmationEmail } from '@/util';
import {
checkNNIDUsernameMinLength,
checkNNIDUsernameMaxLength,
checkNNIDUsernameValidCharacters,
checkNNIDUsernamePunctuationStart,
checkNNIDUsernamePunctuationEnd,
checkNNIDUsernameDuplicate,
checkNNIDPasswordMinLength,
checkNNIDPasswordMaxLength,
checkNNIDPasswordAtLeast2CharacterGroups,
checkNNIDPasswordRepeatCharacters,
nintendoPasswordHash,
sendConfirmationEmail
} from '@/util';
import { LOG_ERROR } from '@/logger';
import { PNID } from '@/models/pnid';
import { NEXAccount } from '@/models/nex-account';
@@ -19,17 +32,6 @@ import type { RegisterRequest, DeepPartial } from '@pretendonetwork/grpc/api/reg
import type { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
const PNID_VALID_CHARACTERS_REGEX = /^[\w\-.]*$/;
const PNID_PUNCTUATION_START_REGEX = /^[_\-.]/;
const PNID_PUNCTUATION_END_REGEX = /[_\-.]$/;
const PNID_PUNCTUATION_DUPLICATE_REGEX = /[_\-.]{2,}/;
// * This sucks
const PASSWORD_WORD_OR_NUMBER_REGEX = /(?=.*[a-zA-Z])(?=.*\d).*/;
const PASSWORD_WORD_OR_PUNCTUATION_REGEX = /(?=.*[a-zA-Z])(?=.*[_\-.]).*/;
const PASSWORD_NUMBER_OR_PUNCTUATION_REGEX = /(?=.*\d)(?=.*[_\-.]).*/;
const PASSWORD_REPEATED_CHARACTER_REGEX = /(.)\1\1/;
const DEFAULT_MII_DATA = Buffer.from('AwAAQOlVognnx0GC2/uogAOzuI0n2QAAAEBEAGUAZgBhAHUAbAB0AAAAAAAAAEBAAAAhAQJoRBgmNEYUgRIXaA0AACkAUkhQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGm9', 'base64');
export async function register(request: RegisterRequest): Promise<DeepPartial<LoginResponse>> {
@@ -65,27 +67,27 @@ export async function register(request: RegisterRequest): Promise<DeepPartial<Lo
throw new ServerError(Status.INVALID_ARGUMENT, 'Must enter a username');
}
if (username.length < 6) {
if (!checkNNIDUsernameMinLength(username)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Username is too short');
}
if (username.length > 16) {
if (!checkNNIDUsernameMaxLength(username)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Username is too long');
}
if (!PNID_VALID_CHARACTERS_REGEX.test(username)) {
if (!checkNNIDUsernameValidCharacters(username)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Username contains invalid characters');
}
if (PNID_PUNCTUATION_START_REGEX.test(username)) {
if (!checkNNIDUsernamePunctuationStart(username)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Username cannot begin with punctuation characters');
}
if (PNID_PUNCTUATION_END_REGEX.test(username)) {
if (!checkNNIDUsernamePunctuationEnd(username)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Username cannot end with punctuation characters');
}
if (PNID_PUNCTUATION_DUPLICATE_REGEX.test(username)) {
if (!checkNNIDUsernameDuplicate(username)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Two or more punctuation characters cannot be used in a row');
}
@@ -109,7 +111,7 @@ export async function register(request: RegisterRequest): Promise<DeepPartial<Lo
throw new ServerError(Status.INVALID_ARGUMENT, 'Must enter a password');
}
if (password.length < 6 || password.length > 16) {
if (!checkNNIDPasswordMinLength(password) || !checkNNIDPasswordMaxLength(password)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password must be between 6 and 16 characters long');
}
@@ -117,11 +119,11 @@ export async function register(request: RegisterRequest): Promise<DeepPartial<Lo
throw new ServerError(Status.INVALID_ARGUMENT, 'Password cannot be the same as username');
}
if (!PASSWORD_WORD_OR_NUMBER_REGEX.test(password) && !PASSWORD_WORD_OR_PUNCTUATION_REGEX.test(password) && !PASSWORD_NUMBER_OR_PUNCTUATION_REGEX.test(password)) {
if (!checkNNIDPasswordAtLeast2CharacterGroups(password)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password must have combination of letters, numbers, and/or punctuation characters');
}
if (PASSWORD_REPEATED_CHARACTER_REGEX.test(password)) {
if (!checkNNIDPasswordRepeatCharacters(password)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password may not have 3 repeating characters');
}

View File

@@ -7,7 +7,7 @@ import Mii from 'mii-js';
import deviceCertificateMiddleware from '@/middleware/device-certificate';
import { deviceRatelimit } from '@/middleware/ratelimit';
import { connection as databaseConnection, doesPNIDExist, getPNIDProfileJSONByPID } from '@/database';
import { isValidBirthday, getAgeFromDate, isObject, getValueFromHeaders, nintendoPasswordHash, sendConfirmationEmail, sendPNIDDeletedEmail } from '@/util';
import { isValidBirthday, getAgeFromDate, checkNNIDUsernameValid, checkNNIDPasswordValid, isObject, getValueFromHeaders, nintendoPasswordHash, sendConfirmationEmail, sendPNIDDeletedEmail } from '@/util';
import IP2LocationManager from '@/ip2location';
import { PNID } from '@/models/pnid';
import { NEXAccount } from '@/models/nex-account';
@@ -18,17 +18,6 @@ import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
import type { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account';
import type { Person } from '@/types/services/nnas/person';
const PNID_VALID_CHARACTERS_REGEX = /^[\w\-.]*$/;
const PNID_PUNCTUATION_START_REGEX = /^[_\-.]/;
const PNID_PUNCTUATION_END_REGEX = /[_\-.]$/;
const PNID_PUNCTUATION_DUPLICATE_REGEX = /[_\-.]{2,}/;
// * This sucks
const PASSWORD_WORD_OR_NUMBER_REGEX = /(?=.*[a-zA-Z])(?=.*\d).*/;
const PASSWORD_WORD_OR_PUNCTUATION_REGEX = /(?=.*[a-zA-Z])(?=.*[_\-.]).*/;
const PASSWORD_NUMBER_OR_PUNCTUATION_REGEX = /(?=.*\d)(?=.*[_\-.]).*/;
const PASSWORD_REPEATED_CHARACTER_REGEX = /(.)\1\1/;
// * Taken from https://github.com/cemu-project/Cemu/blob/5ead58008dd984f614e2cb38bd9cb69bd77fd1bb/src/Cemu/ncrypto/ncrypto.cpp#L825
// * which Cemu uses to build it's AuthInfo struct, which is what is used to populate the X-Nintendo-Country header.
// * This header uses the same values as the "country" field in the NNID account data. Assumed to be correct
@@ -311,64 +300,7 @@ router.post('/', deviceRatelimit, deviceCertificateMiddleware, async (request: e
return;
}
// TODO - Centralize this somewhere. Currently these same checks are being done in the gRPC and API service as well
if (!person.user_id) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {
cause: 'userId',
code: '1104',
message: 'User ID format is not valid'
}
}
}).end());
return;
}
if (!PNID_VALID_CHARACTERS_REGEX.test(person.user_id)) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {
cause: 'userId',
code: '1104',
message: 'User ID format is not valid'
}
}
}).end());
return;
}
if (PNID_PUNCTUATION_START_REGEX.test(person.user_id)) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {
cause: 'userId',
code: '1104',
message: 'User ID format is not valid'
}
}
}).end());
return;
}
if (PNID_PUNCTUATION_END_REGEX.test(person.user_id)) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {
cause: 'userId',
code: '1104',
message: 'User ID format is not valid'
}
}
}).end());
return;
}
if (PNID_PUNCTUATION_DUPLICATE_REGEX.test(person.user_id)) {
if (!person.user_id || !checkNNIDUsernameValid(person.user_id)) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {
@@ -413,50 +345,7 @@ router.post('/', deviceRatelimit, deviceCertificateMiddleware, async (request: e
// * return;
// * }
// TODO - Centralize this somewhere, all the same error
if (!person.password) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {
cause: 'password',
code: '0002',
message: 'password format is invalid'
}
}
}).end());
return;
}
if (person.password.length < 6 || person.password.length > 16) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {
cause: 'password',
code: '0002',
message: 'password format is invalid'
}
}
}).end());
return;
}
if (!PASSWORD_WORD_OR_NUMBER_REGEX.test(person.password) && !PASSWORD_WORD_OR_PUNCTUATION_REGEX.test(person.password) && !PASSWORD_NUMBER_OR_PUNCTUATION_REGEX.test(person.password)) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {
cause: 'password',
code: '0002',
message: 'password format is invalid'
}
}
}).end());
return;
}
if (PASSWORD_REPEATED_CHARACTER_REGEX.test(person.password)) {
if (!person.password || !checkNNIDPasswordValid(person.password)) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {

View File

@@ -35,6 +35,88 @@ if (!disabledFeatures.s3) {
});
}
const NNID_VALID_CHARACTERS_REGEX = /^[\w\-.]*$/;
const NNID_PUNCTUATION_START_REGEX = /^[_\-.]/;
const NNID_PUNCTUATION_END_REGEX = /[_\-.]$/;
const NNID_PUNCTUATION_DUPLICATE_REGEX = /[_\-.]{2,}/;
// * This sucks
const PASSWORD_WORD_OR_NUMBER_REGEX = /(?=.*[a-zA-Z])(?=.*\d).*/;
const PASSWORD_WORD_OR_PUNCTUATION_REGEX = /(?=.*[a-zA-Z])(?=.*[_\-.]).*/;
const PASSWORD_NUMBER_OR_PUNCTUATION_REGEX = /(?=.*\d)(?=.*[_\-.]).*/;
const PASSWORD_REPEATED_CHARACTER_REGEX = /(.)\1\1/;
// * Checks if the input NNID username passes all validation rules
export function checkNNIDUsernameValid(username: string): boolean {
return checkNNIDUsernameLength(username) && checkNNIDUsernameValidCharacters(username) && checkNNIDUsernamePunctuationStart(username) && checkNNIDUsernamePunctuationEnd(username) && checkNNIDUsernameDuplicate(username);
}
// * Ensures the NNID username meets the expected size requirements
export function checkNNIDUsernameLength(username: string): boolean {
return checkNNIDUsernameMinLength(username) && checkNNIDUsernameMaxLength(username);
}
// * Ensures the NNID username is at least 6 characters long
export function checkNNIDUsernameMinLength(username: string): boolean {
return username.length >= 6;
}
// * Ensures the NNID username is at most 16 characters long
export function checkNNIDUsernameMaxLength(username: string): boolean {
return username.length <= 16;
}
// * Ensures the NNID username only contains valid characters
export function checkNNIDUsernameValidCharacters(username: string): boolean {
return NNID_VALID_CHARACTERS_REGEX.test(username);
}
// * Ensures the NNID username does not start with a punctuation character
export function checkNNIDUsernamePunctuationStart(username: string): boolean {
return !NNID_PUNCTUATION_START_REGEX.test(username);
}
// * Ensures the NNID username does not end with a punctuation character
export function checkNNIDUsernamePunctuationEnd(username: string): boolean {
return !NNID_PUNCTUATION_END_REGEX.test(username);
}
// * Ensures the NNID username does not use a punctuation character more than twice in a row
export function checkNNIDUsernameDuplicate(username: string): boolean {
return !NNID_PUNCTUATION_DUPLICATE_REGEX.test(username);
}
// * Checks if the input NNID password passes all validation rules
export function checkNNIDPasswordValid(password: string): boolean {
return checkNNIDPasswordLength(password) && checkNNIDPasswordAtLeast2CharacterGroups(password) && checkNNIDPasswordRepeatCharacters(password);
}
// * Ensures the NNID password meets the expected size requirements
export function checkNNIDPasswordLength(password: string): boolean {
return checkNNIDPasswordMinLength(password) && checkNNIDPasswordMaxLength(password);
}
// * Ensures the NNID password is at least 6 characters long
export function checkNNIDPasswordMinLength(password: string): boolean {
return password.length >= 6;
}
// * Ensures the NNID password is at most 16 characters long
export function checkNNIDPasswordMaxLength(password: string): boolean {
return password.length <= 16;
}
// * Ensures the NNID password has a mix of letters, numbers, and/or punctuation characters.
// * Passwords must contain letters from at least 2 of the 3 groups (letters, numbers, and punctuation)
export function checkNNIDPasswordAtLeast2CharacterGroups(password: string): boolean {
return PASSWORD_WORD_OR_NUMBER_REGEX.test(password) || PASSWORD_WORD_OR_PUNCTUATION_REGEX.test(password) || PASSWORD_NUMBER_OR_PUNCTUATION_REGEX.test(password);
}
// * Ensures the NNID password does not have 3 or more repeating characters
export function checkNNIDPasswordRepeatCharacters(password: string): boolean {
return PASSWORD_REPEATED_CHARACTER_REGEX.test(password);
}
export function nintendoPasswordHash(password: string, pid: number): string {
const pidBuffer = Buffer.alloc(4);
pidBuffer.writeUInt32LE(pid);