Added password reset endpoints for website

This commit is contained in:
Jonathan Barrow
2022-10-01 16:59:39 -04:00
parent 1dff61d7d0
commit 93bfb41a4d
11 changed files with 228 additions and 6 deletions

View File

@@ -28,5 +28,6 @@
"hcaptcha": {
"secret": "0x0000000000000000000000000000000000000000"
},
"cdn_base": "https://example.com"
"cdn_base": "https://example.com",
"website_base": "https://example.com"
}

16
package-lock.json generated
View File

@@ -36,6 +36,7 @@
"nodemailer": "^6.4.2",
"redis": "^4.3.1",
"tga": "^1.0.4",
"validator": "^13.7.0",
"xmlbuilder": "^13.0.2",
"xmlbuilder2": "0.0.4"
},
@@ -3561,6 +3562,14 @@
"uuid": "bin/uuid"
}
},
"node_modules/validator": {
"version": "13.7.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz",
"integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -5378,7 +5387,7 @@
},
"mii-js": {
"version": "git+ssh://git@github.com/PretendoNetwork/mii-js.git#5d8eb8013514a13b0df6eb4a5bfd8b5a63fb9861",
"from": "mii-js@https://github.com/PretendoNetwork/mii-js",
"from": "mii-js@github:PretendoNetwork/mii-js",
"requires": {
"bit-buffer": "^0.2.5"
}
@@ -6514,6 +6523,11 @@
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="
},
"validator": {
"version": "13.7.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz",
"integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw=="
},
"vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@@ -47,6 +47,7 @@
"nodemailer": "^6.4.2",
"redis": "^4.3.1",
"tga": "^1.0.4",
"validator": "^13.7.0",
"xmlbuilder": "^13.0.2",
"xmlbuilder2": "0.0.4"
},

View File

@@ -54,6 +54,16 @@ async function getUserByPID(pid) {
return user;
}
async function getUserByEmailAddress(email) {
verifyConnected();
const user = await PNID.findOne({
'email.address': email.toLowerCase()
});
return user;
}
async function doesUserExist(username) {
verifyConnected();
@@ -252,6 +262,7 @@ module.exports = {
connection,
getUserByUsername,
getUserByPID,
getUserByEmailAddress,
doesUserExist,
getUserBasic,
getUserBearer,

View File

@@ -19,8 +19,10 @@ api.options('*', cors());
logger.info('[USER API] Applying imported routes');
api.use('/v1/connections', routes.V1.CONNECTIONS);
api.use('/v1/email', routes.V1.EMAIL);
api.use('/v1/forgot-password', routes.V1.FORGOT_PASSWORD);
api.use('/v1/login', routes.V1.LOGIN);
api.use('/v1/register', routes.V1.REGISTER);
api.use('/v1/reset-password', routes.V1.RESET_PASSWORD);
api.use('/v1/user', routes.V1.USER);

View File

@@ -2,8 +2,10 @@ module.exports = {
V1: {
CONNECTIONS: require('./v1/connections'),
EMAIL: require('./v1/email'),
FORGOT_PASSWORD: require('./v1/forgotPassword'),
LOGIN: require('./v1/login'),
REGISTER: require('./v1/register'),
RESET_PASSWORD: require('./v1/resetPassword'),
USER: require('./v1/user'),
}
};

View File

@@ -0,0 +1,36 @@
const router = require('express').Router();
const validator = require('validator');
const database = require('../../../../database');
const util = require('../../../../util');
router.post('/', async (request, response) => {
const { body } = request;
const { input } = body;
if (!input || input.trim() === '') {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing input'
});
}
let pnid;
if (validator.isEmail(input)) {
pnid = await database.getUserByEmailAddress(input);
} else {
pnid = await database.getUserByUsername(input);
}
if (pnid) {
await util.sendForgotPasswordEmail(pnid);
}
response.json({
app: 'api',
status: 200
});
});
module.exports = router;

View File

@@ -261,7 +261,7 @@ router.post('/', async (request, response) => {
country: 'US', // TODO: Change this
language: 'en', // TODO: Change this
email: {
address: email,
address: email.toLowerCase(),
primary: true, // TODO: Change this
parent: true, // TODO: Change this
reachable: false, // TODO: Change this

View File

@@ -0,0 +1,127 @@
const router = require('express').Router();
const bcrypt = require('bcrypt');
const { PNID } = require('../../../../models/pnid');
const util = require('../../../../util');
// 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/;
router.post('/', async (request, response) => {
const { body } = request;
const password = body.password?.trim();
const passwordConfirm = body.password_confirm?.trim();
const token = body.token?.trim();
if (!token || token === '') {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Missing token'
});
}
let unpackedToken;
try {
const decryptedToken = await util.decryptToken(Buffer.from(token, 'base64'));
unpackedToken = util.unpackToken(decryptedToken);
} catch (error) {
console.log(error);
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid token'
});
}
if (unpackedToken.expire_time < Date.now()) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Token expired'
});
}
const pnid = await PNID.findOne({ pid: unpackedToken.pid });
if (!pnid) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid token. No user found'
});
}
if (!password || password === '') {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Must enter a password'
});
}
if (password.length < 6) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Password is too short'
});
}
if (password.length > 16) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Password is too long'
});
}
if (password.toLowerCase() === pnid.get('usernameLower')) {
return response.status(400).json({
app: 'api',
status: 400,
error: '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)) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Password must have combination of letters, numbers, and/or punctuation characters'
});
}
if (PASSWORD_REPEATED_CHARACTER_REGEX.test(password)) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Password may not have 3 repeating characters'
});
}
if (password !== passwordConfirm) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Passwords do not match'
});
}
const primaryPasswordHash = util.nintendoPasswordHash(password, pnid.get('pid'));
const passwordHash = await bcrypt.hash(primaryPasswordHash, 10);
pnid.password = passwordHash;
await pnid.save();
response.json({
app: 'api',
status: 200
});
});
module.exports = router;

View File

@@ -114,7 +114,7 @@ router.post('/', ratelimit, deviceCertificateMiddleware, async (request, respons
country: person.get('country'),
language: person.get('language'),
email: {
address: person.get('email').get('address'),
address: person.get('email').get('address').toLowerCase(),
primary: person.get('email').get('primary') === 'Y',
parent: person.get('email').get('parent') === 'Y',
reachable: false,
@@ -474,7 +474,7 @@ router.put('/@me/emails/@primary', async (request, response) => {
}).end());
}
pnid.set('email.address', email.get('address'));
pnid.set('email.address', email.get('address').toLowerCase());
pnid.set('email.reachable', false);
pnid.set('email.validated', false);
pnid.set('email.validated_date', '');

View File

@@ -245,6 +245,33 @@ async function sendEmailConfirmedEmail(pnid) {
});
}
async function sendForgotPasswordEmail(pnid) {
const publicKey = await cache.getServicePublicKey('account');
const secretKey = await cache.getServiceSecretKey('account');
const cryptoOptions = {
public_key: publicKey,
hmac_secret: secretKey
};
const tokenOptions = {
system_type: 0xF, // API
token_type: 0x5, // Password reset
pid: pnid.get('pid'),
access_level: pnid.get('access_level'),
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (24 * 60 * 60 * 1000)) // Only valid for 24 hours
};
const passwordResetToken = await generateToken(cryptoOptions, tokenOptions);
await mailer.sendMail({
to: pnid.get('email.address'),
subject: '[Pretendo Network] Forgot Password',
html: `Visit this link to reset your password ${config.website_base}/account/reset-password?token=${encodeURIComponent(passwordResetToken)}`
});
}
module.exports = {
nintendoPasswordHash,
nintendoBase64Decode,
@@ -256,5 +283,6 @@ module.exports = {
uploadCDNAsset,
nascError,
sendConfirmationEmail,
sendEmailConfirmedEmail
sendEmailConfirmedEmail,
sendForgotPasswordEmail
};