Merge pull request #341 from TeamNewGuys:zkldi/issue-319-Reset-Password/Forgot-Password-functionality

Add password reset flow under /v1/auth
This commit is contained in:
zkldi
2021-09-07 17:48:30 +01:00
committed by GitHub
9 changed files with 209 additions and 2 deletions
+4
View File
@@ -24,4 +24,8 @@
TYPE: "omni",
ENABLE_SERVER_HTTPS: false,
RUN_OWN_CDN: true,
OUR_URL: "https://example.com",
EMAIL_CONFIG: {
FROM: "test <zkldi@example.com>",
}
}
+3
View File
@@ -179,6 +179,8 @@ const db = {
monkDB.get<{ code: string; userID: integer; createdOn: number }>("oauth2-auth-codes"),
"fer-settings": monkDB.get<FervidexSettingsDocument>("fer-settings"),
"orphan-chart-queue": monkDB.get<OrphanChart>("orphan-chart-queue"),
"password-reset-codes":
monkDB.get<{ code: string; userID: integer; createdOn: number }>("password-reset-codes"),
};
export type StaticDatabases =
@@ -211,6 +213,7 @@ export type StaticDatabases =
| "oauth2-auth-codes"
| "fer-settings"
| "orphan-chart-queue"
| "password-reset-codes"
| "user-settings";
export type Databases = StaticDatabases | `songs-${Game}` | `charts-${Game}`;
+26
View File
@@ -0,0 +1,26 @@
// Email HTML is a hellish mess of IE5 era nonsense.
// Good luck.
import { ServerConfig, ServerTypeInfo } from "lib/setup/config";
export function EmailFormatResetPassword(username: string, resetCode: string, ipAddr: string) {
return MainHTMLWrapper(
`Hey ${username}, you've recieved a password reset request.<br/><a href="${ServerConfig.OUR_URL}/password-reset?code=${resetCode}">Click here</a> to perform the reset.<br/>If you did not request this reset, report this! This reset request was made by ${ipAddr}.`
);
}
export function MainHTMLWrapper(innerHTML: string) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
<title>${ServerTypeInfo.name}</title>
</head>
<body>
${innerHTML}
</body>
</html>`;
}
+2
View File
@@ -74,6 +74,7 @@ export interface TachiConfig {
};
USC_QUEUE_SIZE: integer;
BEATORAJA_QUEUE_SIZE: integer;
OUR_URL: string;
}
const isValidOauth2 = p.optional({
@@ -111,6 +112,7 @@ const err = p(config, {
}),
USC_QUEUE_SIZE: p.optional(p.gteInt(2)),
BEATORAJA_QUEUE_SIZE: p.optional(p.gteInt(2)),
OUR_URL: "string",
});
if (err) {
+6 -2
View File
@@ -81,13 +81,17 @@ const DEFAULT_USER_SETTINGS: UserSettings["preferences"] = {
invisible: false,
};
export function HashPassword(plaintext: string) {
return bcrypt.hash(plaintext, BCRYPT_SALT_ROUNDS);
}
export async function AddNewUser(
username: string,
password: string,
plaintext: string,
email: string,
userID: integer
) {
const hashedPassword = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
const hashedPassword = await HashPassword(plaintext);
logger.verbose(`Hashed password for ${username}.`);
@@ -3,6 +3,7 @@ import db from "external/mongo/db";
import mockApi from "test-utils/mock-api";
import ResetDBState from "test-utils/resets";
import { Sleep } from "utils/misc";
t.test("POST /api/v1/auth/login", (t) => {
t.beforeEach(ResetDBState);
@@ -274,3 +275,55 @@ t.test("POST /api/v1/auth/register", (t) => {
t.end();
});
t.test("POST /api/v1/auth/forgot-password", (t) => {
t.beforeEach(ResetDBState);
t.test("Should create a code to reset a password with.", async (t) => {
const res = await mockApi.post("/api/v1/auth/forgot-password").send({
email: "thepasswordis@password.com",
});
t.equal(res.statusCode, 202, "Should return 202 immediately.");
t.strictSame(res.body.body, {}, "Should have no body.");
// We have to wait for this operation to complete, otherwise, this isn't going to work.
// Note that 3seconds is a bit excessive, but better safe than
// sorry!
await Sleep(3_000);
const dbRes = await db["password-reset-codes"].findOne({
userID: 1,
});
t.not(dbRes, null, "Should exist and save a code to the database.");
t.end();
});
t.test(
"Should not create a code to reset a password with if the email does not exist.",
async (t) => {
const res = await mockApi.post("/api/v1/auth/forgot-password").send({
email: "bademail@example.com",
});
t.equal(res.statusCode, 202, "Should return 202 immediately.");
t.strictSame(res.body.body, {}, "Should have no body.");
await Sleep(3_000);
const dbRes = await db["password-reset-codes"].findOne({
userID: 1,
});
t.equal(dbRes, null, "Should not bother sending a code to the database.");
t.end();
}
);
t.end();
});
@@ -8,6 +8,7 @@ import {
ValidateCaptcha,
MountAuthCookie,
InsertDefaultUserSettings,
HashPassword,
} from "./auth";
import {
CheckIfEmailInUse,
@@ -21,6 +22,10 @@ import db from "external/mongo/db";
import CreateLogCtx from "lib/logger/logger";
import prValidate from "server/middleware/prudence-validate";
import { DecrementCounterValue, GetNextCounterValue } from "utils/db";
import { SendEmail } from "lib/email/client";
import { EmailFormatResetPassword } from "lib/email/formats";
import { Random20Hex } from "utils/misc";
import { ServerConfig } from "lib/setup/config";
const logger = CreateLogCtx(__filename);
@@ -298,4 +303,109 @@ router.post("/logout", (req, res) => {
});
});
/**
* Creates a password reset code for a user. The user will then
* be able to trigger POST /reset-password with that code.
*
* @param email - The email associated with the account you want to reset.
*
* @name POST /api/v1/auth/forgot-password
*/
router.post("/forgot-password", prValidate({ email: "string" }), async (req, res) => {
if (!ServerConfig.EMAIL_CONFIG) {
return res.status(501).json({
success: false,
description: `This server does not support password resets.`,
});
}
logger.debug(`Recieved password reset request for ${req.body.email}.`);
// For timing attack and infosec reasons, we can't do anything but **immediately** return here.
res.status(202).json({
success: true,
description: "A code has been sent to your email.",
body: {},
});
const userPrivateInfo = await db["user-private-information"].findOne({ email: req.body.email });
if (userPrivateInfo) {
const user = await db.users.findOne({ id: userPrivateInfo.userID });
if (!user) {
logger.severe(
`User ${userPrivateInfo.userID} has private information but no real account.`
);
return;
}
const code = `M${Random20Hex()}`;
logger.verbose(`Created password reset code for ${FormatUserDoc(user)}.`);
await db["password-reset-codes"].insert({
code,
userID: user.id,
createdOn: Date.now(),
});
await SendEmail(
userPrivateInfo.email,
EmailFormatResetPassword(user.username, code, req.ip)
);
} else {
logger.info(
`Silently rejected password reset request for ${req.body.email}, as no user has this email.`
);
}
});
/**
* Takes a code generated from /forgot-password, a new password,
* and performs the reset for the user.
*
* @param password - The users new password.
* @param code - The code to use to reset this password.
*
* @name POST /api/v1/auth/reset-password
*/
router.post(
"/reset-password",
prValidate({
code: "string",
password: ValidatePassword,
}),
async (req, res) => {
const code = await db["password-reset-codes"].findOne({
code: req.body.code,
});
if (!code) {
return res.status(404).json({
success: false,
description: `This code does not exist.`,
});
}
const encryptedPassword = await HashPassword(req.body.password);
await db["user-private-information"].update(
{
userID: code.userID,
},
{
$set: {
password: encryptedPassword,
},
}
);
return res.status(200).json({
success: true,
description: `Reset your password.`,
body: {},
});
}
);
export default router;
@@ -0,0 +1 @@
[]
+4
View File
@@ -112,3 +112,7 @@ export function IsValidURL(string: string) {
return false;
}
}
export function Sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(() => resolve(), ms));
}