diff --git a/example.config.json b/example.config.json index 3a24e56..2695feb 100644 --- a/example.config.json +++ b/example.config.json @@ -28,5 +28,6 @@ "hcaptcha": { "secret": "0x0000000000000000000000000000000000000000" }, - "cdn_base": "https://example.com" + "cdn_base": "https://example.com", + "website_base": "https://example.com" } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2906080..00a2f1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4bd8c49..1ef4be4 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/database.js b/src/database.js index e46ce89..53b9cdc 100644 --- a/src/database.js +++ b/src/database.js @@ -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, diff --git a/src/services/api/index.js b/src/services/api/index.js index 4426397..8db69f5 100644 --- a/src/services/api/index.js +++ b/src/services/api/index.js @@ -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); diff --git a/src/services/api/routes/index.js b/src/services/api/routes/index.js index 2f56b2e..217a96d 100644 --- a/src/services/api/routes/index.js +++ b/src/services/api/routes/index.js @@ -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'), } }; \ No newline at end of file diff --git a/src/services/api/routes/v1/forgotPassword.js b/src/services/api/routes/v1/forgotPassword.js new file mode 100644 index 0000000..7dd4948 --- /dev/null +++ b/src/services/api/routes/v1/forgotPassword.js @@ -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; \ No newline at end of file diff --git a/src/services/api/routes/v1/register.js b/src/services/api/routes/v1/register.js index 95b2d50..5f91aac 100644 --- a/src/services/api/routes/v1/register.js +++ b/src/services/api/routes/v1/register.js @@ -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 diff --git a/src/services/api/routes/v1/resetPassword.js b/src/services/api/routes/v1/resetPassword.js new file mode 100644 index 0000000..8afb3b1 --- /dev/null +++ b/src/services/api/routes/v1/resetPassword.js @@ -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; \ No newline at end of file diff --git a/src/services/nnid/routes/people.js b/src/services/nnid/routes/people.js index 155d6df..1eb0467 100644 --- a/src/services/nnid/routes/people.js +++ b/src/services/nnid/routes/people.js @@ -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', ''); diff --git a/src/util.js b/src/util.js index c265699..32f3cab 100644 --- a/src/util.js +++ b/src/util.js @@ -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 }; \ No newline at end of file