From bc246e2f96e9a980d209a7bde123080b145909b5 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Wed, 24 Nov 2021 06:37:12 +0000 Subject: [PATCH 1/3] add new eslint rule to catch unawaited promises --- server/.eslintrc | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server/.eslintrc b/server/.eslintrc index 9cf44c8b1..976dbe7b3 100644 --- a/server/.eslintrc +++ b/server/.eslintrc @@ -8,6 +8,19 @@ "@typescript-eslint", "import" ], + "overrides": [ + { + "files": [ + "*.ts", + "*.tsx" + ], + "parserOptions": { + "project": [ + "./tsconfig.json" + ] + } + } + ], "parser": "@typescript-eslint/parser", "parserOptions": { "sourceType": "module", @@ -37,6 +50,7 @@ ], "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-misused-promises": "error", "@typescript-eslint/no-unused-vars": [ "warn", { From b01910c8261ae907846a9fabc79288582cdbdd46 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Wed, 24 Nov 2021 06:38:43 +0000 Subject: [PATCH 2/3] void return isnt important to me --- server/.eslintrc | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/.eslintrc b/server/.eslintrc index 976dbe7b3..b04d3cfe1 100644 --- a/server/.eslintrc +++ b/server/.eslintrc @@ -50,7 +50,12 @@ ], "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-misused-promises": "error", + "@typescript-eslint/no-misused-promises": [ + "error", + { + "checksVoidReturn": false + } + ], "@typescript-eslint/no-unused-vars": [ "warn", { From 34400aeb1e49972739689248a92f3421a0771130 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Wed, 24 Nov 2021 06:44:30 +0000 Subject: [PATCH 3/3] Add change-password endpoint. --- .../api/v1/users/_userID/router.test.ts | 74 ++++++++++++++++++ .../router/api/v1/users/_userID/router.ts | 78 +++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/server/src/server/router/api/v1/users/_userID/router.test.ts b/server/src/server/router/api/v1/users/_userID/router.test.ts index 5b5c3f960..2254e6827 100644 --- a/server/src/server/router/api/v1/users/_userID/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/router.test.ts @@ -4,6 +4,7 @@ import t from "tap"; import { CreateFakeAuthCookie } from "test-utils/fake-auth"; import mockApi from "test-utils/mock-api"; import ResetDBState from "test-utils/resets"; +import { HashPassword, PasswordCompare } from "../../auth/auth"; t.test("GET /api/v1/users/:userID", (t) => { t.beforeEach(ResetDBState); @@ -232,6 +233,79 @@ t.test("PATCH /api/v1/users/:userID", async (t) => { t.end(); }); +t.test("POST /api/v1/users/:userID/change-password", async (t) => { + t.beforeEach(ResetDBState); + const cookie = await CreateFakeAuthCookie(mockApi); + + function send(content: Record) { + return mockApi.post("/api/v1/users/1/change-password").set("Cookie", cookie).send(content); + } + + t.test("Must require !password and !oldPassword to be a valid password.", async (t) => { + const res = await send({ + "!password": null, + "!oldPassword": "heres_my_old_password", + }); + + t.equal(res.statusCode, 400); + t.match( + res.body.description, + /!password/gu, + "Should be related to the invalid password field." + ); + + const res2 = await send({ + "!password": "heres_my_new_password", + "!oldPassword": "short", + }); + + t.equal(res2.statusCode, 400); + t.match( + res2.body.description, + /!oldPassword/gu, + "Should be related to the invalid !oldPassword field." + ); + + t.end(); + }); + + t.test("Must require !oldPassword to match.", async (t) => { + const res = await send({ + "!password": "new_password", + "!oldPassword": "NOT_MY_PASSWORD", + }); + + t.equal(res.statusCode, 401); + t.match(res.body.description, /old password doesn't match/iu); + + t.end(); + }); + + t.test("Should update password if everything checks out.", async (t) => { + const res = await send({ + "!password": "NEW_PASSWORD", + "!oldPassword": "password", + }); + + t.equal(res.statusCode, 200); + + const newPrivateInfo = await db["user-private-information"].findOne({ + userID: 1, + }); + + t.not(newPrivateInfo, null, "newPrivateInfo should not be null."); + + t.ok( + PasswordCompare("NEW_PASSWORD", newPrivateInfo!.password), + "Should update the users password to the hash of NEW_PASSWORD." + ); + + t.end(); + }); + + t.end(); +}); + t.test("GET /api/v1/users/:userID/game-stats", (t) => { t.beforeEach(ResetDBState); diff --git a/server/src/server/router/api/v1/users/_userID/router.ts b/server/src/server/router/api/v1/users/_userID/router.ts index 02c4583a5..e63b8ec28 100644 --- a/server/src/server/router/api/v1/users/_userID/router.ts +++ b/server/src/server/router/api/v1/users/_userID/router.ts @@ -13,6 +13,7 @@ import { GetRecentSessions, } from "utils/queries/summary"; import { FormatUserDoc, GetAllRankings, GetUserWithID } from "utils/user"; +import { HashPassword, PasswordCompare, ValidatePassword } from "../../auth/auth"; import apiTokensRouter from "./api-tokens/router"; import bannerRouter from "./banner/router"; import gamePTRouter from "./games/_game/_playtype/router"; @@ -263,6 +264,83 @@ router.get("/is-email-verified", RequireSelfRequestFromUser, async (req, res) => }); }); +/** + * Changes the users password. + * Requires self-key level permissions. + * + * @param !password - The new password. Must pass password validation rules. + * @param !oldPassword - The old password. + * + * @name POST /api/v1/users/:userID/change-password + */ +router.post( + "/change-password", + RequireSelfRequestFromUser, + prValidate({ + "!password": ValidatePassword, + "!oldPassword": ValidatePassword, + }), + async (req, res) => { + const user = req.session.tachi?.user; + + /* istanbul ignore next */ + if (!user) { + logger.severe( + `IP ${req.ip} got to /change-password without a user, but passed RequireSelfRequest?` + ); + // this should be a 500, but lie to them. + return res.status(403).json({ + success: false, + description: `You are not authorised to perform this action.`, + }); + } + + const privateInfo = await db["user-private-information"].findOne({ + userID: user.id, + }); + + /* istanbul ignore next */ + if (!privateInfo) { + logger.severe(`User ${FormatUserDoc(user)} has no private information?`, { user }); + return res.status(500).json({ + success: false, + description: `An internal server error has occured.`, + }); + } + + const isLastPasswordValid = await PasswordCompare( + req.body["!oldPassword"], + privateInfo.password + ); + + if (!isLastPasswordValid) { + return res.status(401).json({ + success: false, + description: `Old Password doesn't match what we have in our records.`, + }); + } + + const newPasswordHash = await HashPassword(req.body["!password"]); + + await db["user-private-information"].update( + { + userID: user.id, + }, + { + $set: { + password: newPasswordHash, + }, + } + ); + + return res.status(200).json({ + success: true, + description: `Updated Password.`, + body: {}, + }); + } +); + router.use("/games/:game/:playtype", gamePTRouter); router.use("/pfp", pfpRouter); router.use("/banner", bannerRouter);