diff --git a/server/.github/test.conf.json5 b/server/.github/test.conf.json5 index 4959e1f11..b0c8c8ab9 100644 --- a/server/.github/test.conf.json5 +++ b/server/.github/test.conf.json5 @@ -24,4 +24,8 @@ TYPE: "omni", ENABLE_SERVER_HTTPS: false, RUN_OWN_CDN: true, + OUR_URL: "https://example.com", + EMAIL_CONFIG: { + FROM: "test ", + } } diff --git a/server/src/external/mongo/db.ts b/server/src/external/mongo/db.ts index 6988848e9..a322514fa 100644 --- a/server/src/external/mongo/db.ts +++ b/server/src/external/mongo/db.ts @@ -179,6 +179,8 @@ const db = { monkDB.get<{ code: string; userID: integer; createdOn: number }>("oauth2-auth-codes"), "fer-settings": monkDB.get("fer-settings"), "orphan-chart-queue": monkDB.get("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}`; diff --git a/server/src/lib/email/formats.ts b/server/src/lib/email/formats.ts new file mode 100644 index 000000000..2c0c0d237 --- /dev/null +++ b/server/src/lib/email/formats.ts @@ -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.
Click here to perform the reset.
If you did not request this reset, report this! This reset request was made by ${ipAddr}.` + ); +} + +export function MainHTMLWrapper(innerHTML: string) { + return ` + + + + + + + ${ServerTypeInfo.name} + + + ${innerHTML} + + `; +} diff --git a/server/src/lib/setup/config.ts b/server/src/lib/setup/config.ts index ca8139074..7491a2060 100644 --- a/server/src/lib/setup/config.ts +++ b/server/src/lib/setup/config.ts @@ -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) { diff --git a/server/src/server/router/api/v1/auth/auth.ts b/server/src/server/router/api/v1/auth/auth.ts index c2c790bbf..125ea8af7 100644 --- a/server/src/server/router/api/v1/auth/auth.ts +++ b/server/src/server/router/api/v1/auth/auth.ts @@ -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}.`); diff --git a/server/src/server/router/api/v1/auth/router.test.ts b/server/src/server/router/api/v1/auth/router.test.ts index 59b90fabd..ee206eaef 100644 --- a/server/src/server/router/api/v1/auth/router.test.ts +++ b/server/src/server/router/api/v1/auth/router.test.ts @@ -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(); +}); diff --git a/server/src/server/router/api/v1/auth/router.ts b/server/src/server/router/api/v1/auth/router.ts index 4d540eb79..ce35d6258 100644 --- a/server/src/server/router/api/v1/auth/router.ts +++ b/server/src/server/router/api/v1/auth/router.ts @@ -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; diff --git a/server/src/test-utils/mock-db/password-reset-codes.json b/server/src/test-utils/mock-db/password-reset-codes.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/server/src/test-utils/mock-db/password-reset-codes.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index ea5ff19be..7190fecae 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -112,3 +112,7 @@ export function IsValidURL(string: string) { return false; } } + +export function Sleep(ms: number) { + return new Promise((resolve) => setTimeout(() => resolve(), ms)); +}