mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-04 21:08:09 +03:00
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:
Vendored
+4
@@ -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>",
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+3
@@ -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}`;
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 @@
|
||||
[]
|
||||
@@ -112,3 +112,7 @@ export function IsValidURL(string: string) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function Sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(() => resolve(), ms));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user