feat: maintain hashed blocklist of banned usernames, emails and discordids (fehmer) (#5371)

* feat: maintain blocklist of banned usernames and email (fehmer)

* update privacy policy

---------

Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
Christian Fehmer 2024-05-20 12:21:14 +02:00 committed by GitHub
parent e99e283191
commit 4589bbf679
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1453 additions and 422 deletions

View file

@ -4,11 +4,35 @@ import * as Configuration from "../../../src/init/configuration";
import { getCurrentTestActivity } from "../../../src/api/controllers/user";
import * as UserDal from "../../../src/dal/user";
import _ from "lodash";
import { DecodedIdToken } from "firebase-admin/lib/auth/token-verifier";
import * as AuthUtils from "../../../src/utils/auth";
import * as BlocklistDal from "../../../src/dal/blocklist";
import * as ApeKeys from "../../../src/dal/ape-keys";
import * as PresetDal from "../../../src/dal/preset";
import * as ConfigDal from "../../../src/dal/config";
import * as ResultDal from "../../../src/dal/result";
import * as DailyLeaderboards from "../../../src/utils/daily-leaderboards";
import GeorgeQueue from "../../../src/queues/george-queue";
import * as AdminUuids from "../../../src/dal/admin-uids";
import * as DiscordUtils from "../../../src/utils/discord";
const mockApp = request(app);
const configuration = Configuration.getCachedConfiguration();
const mockDecodedToken: DecodedIdToken = {
uid: "123456789",
email: "newuser@mail.com",
iat: Date.now(),
} as DecodedIdToken;
describe("user controller test", () => {
beforeEach(() => {
vi.spyOn(AuthUtils, "verifyIdToken").mockResolvedValue(mockDecodedToken);
});
describe("user creation flow", () => {
beforeEach(async () => {
await enableSignup(true);
});
it("should be able to check name, sign up, and get user data", async () => {
await mockApp
.get("/users/checkName/NewUser")
@ -24,42 +48,6 @@ describe("user controller test", () => {
captcha: "captcha",
};
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue({
//if stuff breaks this might be the reason
users: {
signUp: true,
discordIntegration: {
enabled: false,
},
autoBan: {
enabled: false,
maxCount: 5,
maxHours: 1,
},
profiles: {
enabled: false,
},
xp: {
enabled: false,
gainMultiplier: 0,
maxDailyBonus: 0,
minDailyBonus: 0,
streak: {
enabled: false,
maxStreakDays: 0,
maxStreakMultiplier: 0,
},
},
inbox: {
enabled: false,
maxMail: 0,
},
premium: {
enabled: true,
},
},
} as any);
await mockApp
.post("/users/signup")
.set("authorization", "Uid 123456789|newuser@mail.com")
@ -92,20 +80,133 @@ describe("user controller test", () => {
Accept: "application/json",
})
.expect(409);
vi.restoreAllMocks();
});
});
describe("user signup", () => {
const blocklistContainsMock = vi.spyOn(BlocklistDal, "contains");
const firebaseDeleteUserMock = vi.spyOn(AuthUtils, "deleteUser");
const usernameAvailableMock = vi.spyOn(UserDal, "isNameAvailable");
beforeEach(async () => {
await enableSignup(true);
usernameAvailableMock.mockResolvedValue(true);
});
afterEach(() => {
[
blocklistContainsMock,
firebaseDeleteUserMock,
usernameAvailableMock,
].forEach((it) => it.mockReset());
});
it("should not create user if blocklisted", async () => {
//GIVEN
blocklistContainsMock.mockResolvedValue(true);
firebaseDeleteUserMock.mockResolvedValue();
const newUser = {
name: "NewUser",
uid: "123456789",
email: "newuser@mail.com",
captcha: "captcha",
};
//WHEN
const result = await mockApp
.post("/users/signup")
.set("authorization", "Uid 123456789|newuser@mail.com")
.send(newUser)
.set({
Accept: "application/json",
})
.expect(409);
//THEN
expect(result.body.message).toEqual("Username or email blocked");
expect(blocklistContainsMock).toHaveBeenCalledWith({
name: "NewUser",
email: "newuser@mail.com",
});
//user will be created in firebase from the frontend, make sure we remove it
expect(firebaseDeleteUserMock).toHaveBeenCalledWith("123456789");
});
it("should not create user domain is blacklisted", async () => {
["tidal.lol", "selfbot.cc"].forEach(async (domain) => {
//GIVEN
firebaseDeleteUserMock.mockResolvedValue();
const newUser = {
name: "NewUser",
uid: "123456789",
email: `newuser@${domain}`,
captcha: "captcha",
};
//WHEN
const result = await mockApp
.post("/users/signup")
.set("authorization", `Uid 123456789|newuser@${domain}`)
.send(newUser)
.set({
Accept: "application/json",
})
.expect(400);
//THEN
expect(result.body.message).toEqual("Invalid domain");
//user will be created in firebase from the frontend, make sure we remove it
expect(firebaseDeleteUserMock).toHaveBeenCalledWith("123456789");
});
});
it("should not create user if username is taken", async () => {
//GIVEN
usernameAvailableMock.mockResolvedValue(false);
firebaseDeleteUserMock.mockResolvedValue();
const newUser = {
name: "NewUser",
uid: "123456789",
email: "newuser@mail.com",
captcha: "captcha",
};
//WHEN
const result = await mockApp
.post("/users/signup")
.set("authorization", "Uid 123456789|newuser@mail.com")
.send(newUser)
.set({
Accept: "application/json",
})
.expect(409);
//THEN
expect(result.body.message).toEqual("Username unavailable");
expect(usernameAvailableMock).toHaveBeenCalledWith(
"NewUser",
"123456789"
);
//user will be created in firebase from the frontend, make sure we remove it
expect(firebaseDeleteUserMock).toHaveBeenCalledWith("123456789");
});
});
describe("getTestActivity", () => {
const getUserMock = vi.spyOn(UserDal, "getUser");
afterAll(() => {
getUserMock.mockReset();
});
it("should return 503 for non premium users", async () => {
//given
vi.spyOn(UserDal, "getUser").mockResolvedValue({
getUserMock.mockResolvedValue({
testActivity: { "2023": [1, 2, 3], "2024": [4, 5, 6] },
} as unknown as MonkeyTypes.DBUser);
//when
const response = await mockApp
await mockApp
.get("/users/testActivity")
.set("authorization", "Uid 123456789")
.send()
@ -113,7 +214,7 @@ describe("user controller test", () => {
});
it("should send data for premium users", async () => {
//given
vi.spyOn(UserDal, "getUser").mockResolvedValue({
getUserMock.mockResolvedValue({
testActivity: { "2023": [1, 2, 3], "2024": [4, 5, 6] },
} as unknown as MonkeyTypes.DBUser);
vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(true);
@ -200,6 +301,305 @@ describe("user controller test", () => {
expect(testsByDays[365]).toEqual(2024094); //2024-01
});
});
describe("toggle ban", () => {
const getUserMock = vi.spyOn(UserDal, "getUser");
const setBannedMock = vi.spyOn(UserDal, "setBanned");
const georgeUserBannedMock = vi.spyOn(GeorgeQueue, "userBanned");
const isAdminMock = vi.spyOn(AdminUuids, "isAdmin");
beforeEach(async () => {
await enableAdminFeatures(true);
isAdminMock.mockResolvedValue(true);
});
afterEach(() => {
[getUserMock, setBannedMock, georgeUserBannedMock, isAdminMock].forEach(
(it) => it.mockReset()
);
});
it("bans user with discord", async () => {
//GIVEN
const uid = "myUid";
const user = {
uid,
name: "name",
email: "email",
discordId: "discordId",
} as unknown as MonkeyTypes.DBUser;
getUserMock.mockResolvedValue(user);
//WHEN
await mockApp
.post("/admin/toggleBan")
.set("Authorization", "Bearer 123456789")
.send({ uid })
.set({
Accept: "application/json",
})
.expect(200);
//THEN
expect(getUserMock).toHaveBeenLastCalledWith(uid, "toggle ban");
expect(setBannedMock).toHaveBeenCalledWith(uid, true);
expect(georgeUserBannedMock).toHaveBeenCalledWith("discordId", true);
});
it("bans user without discord", async () => {
//GIVEN
const uid = "myUid";
const user = {
uid,
name: "name",
email: "email",
discordId: "",
} as unknown as MonkeyTypes.DBUser;
getUserMock.mockResolvedValue(user);
//WHEN
await mockApp
.post("/admin/toggleBan")
.set("Authorization", "Bearer 123456789")
.send({ uid })
.set({
Accept: "application/json",
})
.expect(200);
//THEN
expect(georgeUserBannedMock).not.toHaveBeenCalled();
});
it("unbans user with discord", async () => {
//GIVEN
const uid = "myUid";
const user = {
uid,
name: "name",
email: "email",
discordId: "discordId",
banned: true,
} as unknown as MonkeyTypes.DBUser;
getUserMock.mockResolvedValue(user);
//WHEN
await mockApp
.post("/admin/toggleBan")
.set("Authorization", "Bearer 123456789")
.send({ uid })
.set({
Accept: "application/json",
})
.expect(200);
//THEN
expect(getUserMock).toHaveBeenLastCalledWith(uid, "toggle ban");
expect(setBannedMock).toHaveBeenCalledWith(uid, false);
expect(georgeUserBannedMock).toHaveBeenCalledWith("discordId", false);
});
it("unbans user without discord", async () => {
//GIVEN
const uid = "myUid";
const user = {
uid,
name: "name",
email: "email",
discordId: "",
banned: true,
} as unknown as MonkeyTypes.DBUser;
getUserMock.mockResolvedValue(user);
//WHEN
await mockApp
.post("/admin/toggleBan")
.set("Authorization", "Bearer 123456789")
.send({ uid })
.set({
Accept: "application/json",
})
.expect(200);
//THEN
expect(georgeUserBannedMock).not.toHaveBeenCalled();
});
});
describe("delete user", () => {
const getUserMock = vi.spyOn(UserDal, "getUser");
const deleteUserMock = vi.spyOn(UserDal, "deleteUser");
const firebaseDeleteUserMock = vi.spyOn(AuthUtils, "deleteUser");
const deleteAllApeKeysMock = vi.spyOn(ApeKeys, "deleteAllApeKeys");
const deleteAllPresetsMock = vi.spyOn(PresetDal, "deleteAllPresets");
const deleteConfigMock = vi.spyOn(ConfigDal, "deleteConfig");
const deleteAllResultMock = vi.spyOn(ResultDal, "deleteAll");
const purgeUserFromDailyLeaderboardsMock = vi.spyOn(
DailyLeaderboards,
"purgeUserFromDailyLeaderboards"
);
const blocklistAddMock = vi.spyOn(BlocklistDal, "add");
beforeEach(() => {
[
firebaseDeleteUserMock,
deleteUserMock,
blocklistAddMock,
deleteAllApeKeysMock,
deleteAllPresetsMock,
deleteConfigMock,
purgeUserFromDailyLeaderboardsMock,
].forEach((it) => it.mockResolvedValue(undefined));
deleteAllResultMock.mockResolvedValue({} as any);
});
afterEach(() => {
[
getUserMock,
deleteUserMock,
blocklistAddMock,
firebaseDeleteUserMock,
deleteConfigMock,
deleteAllResultMock,
deleteAllApeKeysMock,
deleteAllPresetsMock,
purgeUserFromDailyLeaderboardsMock,
].forEach((it) => it.mockReset());
});
it("should add user to blocklist if banned", async () => {
//GIVEN
const uid = mockDecodedToken.uid;
const user = {
uid,
name: "name",
email: "email",
discordId: "discordId",
banned: true,
} as unknown as MonkeyTypes.DBUser;
await getUserMock.mockResolvedValue(user);
//WHEN
await mockApp
.delete("/users/")
.set("Authorization", "Bearer 123456789")
.set({
Accept: "application/json",
})
.expect(200);
//THEN
expect(blocklistAddMock).toHaveBeenCalledWith(user);
expect(deleteUserMock).toHaveBeenCalledWith(uid);
expect(firebaseDeleteUserMock).toHaveBeenCalledWith(uid);
expect(deleteAllApeKeysMock).toHaveBeenCalledWith(uid);
expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid);
expect(deleteConfigMock).toHaveBeenCalledWith(uid);
expect(deleteAllResultMock).toHaveBeenCalledWith(uid);
expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith(
uid,
(await configuration).dailyLeaderboards
);
});
it("should delete user without adding to blocklist if not banned", async () => {
//GIVEN
const uid = mockDecodedToken.uid;
const user = {
uid,
name: "name",
email: "email",
discordId: "discordId",
} as unknown as MonkeyTypes.DBUser;
getUserMock.mockResolvedValue(user);
//WHEN
await mockApp
.delete("/users/")
.set("Authorization", "Bearer 123456789")
.set({
Accept: "application/json",
})
.expect(200);
//THEN
expect(blocklistAddMock).not.toHaveBeenCalled();
expect(deleteUserMock).toHaveBeenCalledWith(uid);
expect(firebaseDeleteUserMock).toHaveBeenCalledWith(uid);
expect(deleteAllApeKeysMock).toHaveBeenCalledWith(uid);
expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid);
expect(deleteConfigMock).toHaveBeenCalledWith(uid);
expect(deleteAllResultMock).toHaveBeenCalledWith(uid);
expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith(
uid,
(await configuration).dailyLeaderboards
);
});
});
describe("link discord", () => {
const getUserMock = vi.spyOn(UserDal, "getUser");
const isDiscordIdAvailableMock = vi.spyOn(UserDal, "isDiscordIdAvailable");
const isStateValidForUserMock = vi.spyOn(
DiscordUtils,
"iStateValidForUser"
);
const getDiscordUserMock = vi.spyOn(DiscordUtils, "getDiscordUser");
const blocklistContainsMock = vi.spyOn(BlocklistDal, "contains");
beforeEach(async () => {
isStateValidForUserMock.mockResolvedValue(true);
getDiscordUserMock.mockResolvedValue({
id: "discordUserId",
avatar: "discorUserAvatar",
username: "discordUserName",
discriminator: "discordUserDiscriminator",
});
isDiscordIdAvailableMock.mockResolvedValue(true);
blocklistContainsMock.mockResolvedValue(false);
await enableDiscordIntegration(true);
});
afterEach(() => {
[
getUserMock,
isStateValidForUserMock,
isDiscordIdAvailableMock,
getDiscordUserMock,
].forEach((it) => it.mockReset());
});
it("should not link if discordId is blocked", async () => {
//GIVEN
const uid = mockDecodedToken.uid;
const user = {
uid,
name: "name",
email: "email",
} as unknown as MonkeyTypes.DBUser;
getUserMock.mockResolvedValue(user);
blocklistContainsMock.mockResolvedValue(true);
//WHEN
const result = await mockApp
.post("/users/discord/link")
.set("Authorization", "Bearer 123456789")
.set({
Accept: "application/json",
})
.send({
tokenType: "tokenType",
accessToken: "accessToken",
state: "statestatestatestate",
})
.expect(409);
//THEN
expect(result.body.message).toEqual("The Discord account is blocked");
expect(blocklistContainsMock).toBeCalledWith({
discordId: "discordUserId",
});
});
});
});
function fillYearWithDay(days: number): number[] {
@ -210,8 +610,6 @@ function fillYearWithDay(days: number): number[] {
return result;
}
const configuration = Configuration.getCachedConfiguration();
async function enablePremiumFeatures(premium: boolean): Promise<void> {
const mockConfig = _.merge(await configuration, {
users: { premium: { enabled: premium } },
@ -221,3 +619,33 @@ async function enablePremiumFeatures(premium: boolean): Promise<void> {
mockConfig
);
}
async function enableAdminFeatures(enabled: boolean): Promise<void> {
const mockConfig = _.merge(await configuration, {
admin: { endpointsEnabled: enabled },
});
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig
);
}
async function enableSignup(enabled: boolean): Promise<void> {
const mockConfig = _.merge(await configuration, {
users: { signUp: enabled },
});
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig
);
}
async function enableDiscordIntegration(enabled: boolean): Promise<void> {
const mockConfig = _.merge(await configuration, {
users: { discordIntegration: { enabled } },
});
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig
);
}

View file

@ -0,0 +1,339 @@
import { ObjectId } from "mongodb";
import * as BlacklistDal from "../../src/dal/blocklist";
describe("BlocklistDal", () => {
describe("add", () => {
beforeEach(() => {
vitest.useFakeTimers();
});
afterEach(() => {
vitest.useRealTimers();
});
it("adds user", async () => {
//GIVEN
const now = 1715082588;
vitest.setSystemTime(now);
const name = "user" + new ObjectId().toHexString();
const email = `${name}@example.com`;
//WHEN
await BlacklistDal.add({ name, email });
//THEN
expect(
BlacklistDal.getCollection().findOne({
emailHash: BlacklistDal.hash(email),
})
).resolves.toMatchObject({
emailHash: BlacklistDal.hash(email),
timestamp: now,
});
expect(
BlacklistDal.getCollection().findOne({
usernameHash: BlacklistDal.hash(name),
})
).resolves.toMatchObject({
usernameHash: BlacklistDal.hash(name),
timestamp: now,
});
});
it("adds user with discordId", async () => {
//GIVEN
const now = 1715082588;
vitest.setSystemTime(now);
const name = "user" + new ObjectId().toHexString();
const email = `${name}@example.com`;
const discordId = `${name}DiscordId`;
//WHEN
await BlacklistDal.add({ name, email, discordId });
//THEN
expect(
BlacklistDal.getCollection().findOne({
discordIdHash: BlacklistDal.hash(discordId),
})
).resolves.toMatchObject({
discordIdHash: BlacklistDal.hash(discordId),
timestamp: now,
});
});
it("adds user should not create duplicate name", async () => {
//GIVEN
const now = 1715082588;
vitest.setSystemTime(now);
const name = "user" + new ObjectId().toHexString();
const email = `${name}@example.com`;
const email2 = `${name}@otherdomain.com`;
await BlacklistDal.add({ name, email });
//WHEN
await BlacklistDal.add({ name, email: email2 });
//THEN
expect(
BlacklistDal.getCollection()
.find({
usernameHash: BlacklistDal.hash(name),
})
.toArray()
).resolves.toHaveLength(1);
expect(
BlacklistDal.getCollection()
.find({
emailHash: BlacklistDal.hash(email),
})
.toArray()
).resolves.toHaveLength(1);
expect(
BlacklistDal.getCollection()
.find({
emailHash: BlacklistDal.hash(email2),
})
.toArray()
).resolves.toHaveLength(1);
});
it("adds user should not create duplicate email", async () => {
//GIVEN
const now = 1715082588;
vitest.setSystemTime(now);
const name = "user" + new ObjectId().toHexString();
const email = `${name}@example.com`;
const name2 = "user" + new ObjectId().toHexString();
await BlacklistDal.add({ name, email });
//WHEN
await BlacklistDal.add({ name: name2, email });
//THEN
expect(
BlacklistDal.getCollection()
.find({
emailHash: BlacklistDal.hash(email),
})
.toArray()
).resolves.toHaveLength(1);
});
it("adds user should not create duplicate discordId", async () => {
//GIVEN
const now = 1715082588;
vitest.setSystemTime(now);
const name = "user" + new ObjectId().toHexString();
const name2 = "user" + new ObjectId().toHexString();
const email = `${name}@example.com`;
const discordId = `${name}DiscordId`;
await BlacklistDal.add({ name, email, discordId });
//WHEN
await BlacklistDal.add({ name: name2, email, discordId });
//THEN
expect(
BlacklistDal.getCollection()
.find({
discordIdHash: BlacklistDal.hash(discordId),
})
.toArray()
).resolves.toHaveLength(1);
});
});
describe("contains", () => {
it("contains user", async () => {
//GIVEN
const name = "user" + new ObjectId().toHexString();
const email = `${name}@example.com`;
const discordId = `${name}DiscordId`;
await BlacklistDal.add({ name, email, discordId });
await BlacklistDal.add({ name: "test", email: "test@example.com" });
//WHEN / THEN
//by name
expect(BlacklistDal.contains({ name })).resolves.toBeTruthy();
expect(
BlacklistDal.contains({ name: name.toUpperCase() })
).resolves.toBeTruthy();
expect(
BlacklistDal.contains({ name, email: "unknown", discordId: "unknown" })
).resolves.toBeTruthy();
//by email
expect(BlacklistDal.contains({ email })).resolves.toBeTruthy();
expect(
BlacklistDal.contains({ email: email.toUpperCase() })
).resolves.toBeTruthy();
expect(
BlacklistDal.contains({ name: "unknown", email, discordId: "unknown" })
).resolves.toBeTruthy();
//by discordId
expect(BlacklistDal.contains({ discordId })).resolves.toBeTruthy();
expect(
BlacklistDal.contains({ discordId: discordId.toUpperCase() })
).resolves.toBeTruthy();
expect(
BlacklistDal.contains({ name: "unknown", email: "unknown", discordId })
).resolves.toBeTruthy();
//by name and email and discordId
expect(
BlacklistDal.contains({ name, email, discordId })
).resolves.toBeTruthy();
});
it("does not contain user", async () => {
//GIVEN
await BlacklistDal.add({ name: "test", email: "test@example.com" });
await BlacklistDal.add({ name: "test2", email: "test2@example.com" });
//WHEN / THEN
expect(BlacklistDal.contains({ name: "unknown" })).resolves.toBeFalsy();
expect(BlacklistDal.contains({ email: "unknown" })).resolves.toBeFalsy();
expect(
BlacklistDal.contains({ discordId: "unknown" })
).resolves.toBeFalsy();
expect(
BlacklistDal.contains({
name: "unknown",
email: "unknown",
discordId: "unknown",
})
).resolves.toBeFalsy();
expect(BlacklistDal.contains({})).resolves.toBeFalsy();
});
});
describe("remove", () => {
it("removes existing username", async () => {
//GIVEN
const name = "user" + new ObjectId().toHexString();
const email = `${name}@example.com`;
await BlacklistDal.add({ name, email });
await BlacklistDal.add({ name: "test", email: "test@example.com" });
//WHEN
await BlacklistDal.remove({ name });
//THEN
expect(BlacklistDal.contains({ name })).resolves.toBeFalsy();
expect(BlacklistDal.contains({ email })).resolves.toBeTruthy();
//decoy still exists
expect(BlacklistDal.contains({ name: "test" })).resolves.toBeTruthy();
expect(
BlacklistDal.contains({ email: "test@example.com" })
).resolves.toBeTruthy();
});
it("removes existing email", async () => {
//GIVEN
const name = "user" + new ObjectId().toHexString();
const email = `${name}@example.com`;
await BlacklistDal.add({ name, email });
await BlacklistDal.add({ name: "test", email: "test@example.com" });
//WHEN
await BlacklistDal.remove({ email });
//THEN
expect(BlacklistDal.contains({ email })).resolves.toBeFalsy();
expect(BlacklistDal.contains({ name })).resolves.toBeTruthy();
//decoy still exists
expect(BlacklistDal.contains({ name: "test" })).resolves.toBeTruthy();
expect(
BlacklistDal.contains({ email: "test@example.com" })
).resolves.toBeTruthy();
});
it("removes existing discordId", async () => {
//GIVEN
const name = "user" + new ObjectId().toHexString();
const email = `${name}@example.com`;
const discordId = `${name}DiscordId`;
await BlacklistDal.add({ name, email, discordId });
await BlacklistDal.add({
name: "test",
email: "test@example.com",
discordId: "testDiscordId",
});
//WHEN
await BlacklistDal.remove({ discordId });
//THEN
expect(BlacklistDal.contains({ discordId })).resolves.toBeFalsy();
expect(BlacklistDal.contains({ name })).resolves.toBeTruthy();
expect(BlacklistDal.contains({ email })).resolves.toBeTruthy();
//decoy still exists
expect(BlacklistDal.contains({ name: "test" })).resolves.toBeTruthy();
expect(
BlacklistDal.contains({ email: "test@example.com" })
).resolves.toBeTruthy();
expect(
BlacklistDal.contains({ discordId: "testDiscordId" })
).resolves.toBeTruthy();
});
it("removes existing username,email and discordId", async () => {
//GIVEN
const name = "user" + new ObjectId().toHexString();
const email = `${name}@example.com`;
const discordId = `${name}DiscordId`;
await BlacklistDal.add({ name, email, discordId });
await BlacklistDal.add({
name: "test",
email: "test@example.com",
discordId: "testDiscordId",
});
//WHEN
await BlacklistDal.remove({ name, email, discordId });
//THEN
expect(BlacklistDal.contains({ email })).resolves.toBeFalsy();
expect(BlacklistDal.contains({ name })).resolves.toBeFalsy();
expect(BlacklistDal.contains({ discordId })).resolves.toBeFalsy();
//decoy still exists
expect(BlacklistDal.contains({ name: "test" })).resolves.toBeTruthy();
expect(
BlacklistDal.contains({ email: "test@example.com" })
).resolves.toBeTruthy();
expect(
BlacklistDal.contains({ discordId: "testDiscordId" })
).resolves.toBeTruthy();
});
it("does not remove for empty user", async () => {
//GIVEN
const name = "user" + new ObjectId().toHexString();
const email = `${name}@example.com`;
const discordId = `${name}DiscordId`;
await BlacklistDal.add({ name, email, discordId });
await BlacklistDal.add({ name: "test", email: "test@example.com" });
//WHEN
await BlacklistDal.remove({});
//THEN
expect(BlacklistDal.contains({ email })).resolves.toBeTruthy();
expect(BlacklistDal.contains({ name })).resolves.toBeTruthy();
expect(BlacklistDal.contains({ discordId })).resolves.toBeTruthy();
});
});
describe("hash", () => {
it("hashes case insensitive", () => {
["test", "TEST", "tESt"].forEach((value) =>
expect(BlacklistDal.hash(value)).toEqual(
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
)
);
});
});
});

View file

@ -0,0 +1,14 @@
import * as MongoDbMock from "vitest-mongodb";
export async function setup({ provide }): Promise<void> {
await MongoDbMock.setup({
serverOptions: {
binary: {
version: "6.0.12",
},
},
});
}
export async function teardown(): Promise<void> {
await MongoDbMock.teardown();
}

View file

@ -1,78 +1,77 @@
import { Collection, Db, MongoClient, WithId } from "mongodb";
import { afterAll, beforeAll, beforeEach, afterEach } from "vitest";
import { afterAll, beforeAll, afterEach } from "vitest";
import * as MongoDbMock from "vitest-mongodb";
process.env["MODE"] = "dev";
vi.mock("../src/init/db", () => ({
__esModule: true,
getDb: (): Db => db,
collection: <T>(name: string): Collection<WithId<T>> =>
db.collection<WithId<T>>(name),
close: () => client?.close(),
}));
vi.mock("../src/utils/logger", () => ({
__esModule: true,
default: {
error: console.error,
warning: console.warn,
info: console.info,
success: console.info,
logToDb: console.info,
},
}));
vi.mock("swagger-stats", () => ({
getMiddleware:
() =>
(_: unknown, __: unknown, next: () => unknown): void => {
next();
},
}));
//process.env["MONGOMS_DISTRO"] = "ubuntu-22.04";
if (!process.env["REDIS_URI"]) {
// use mock if not set
process.env["REDIS_URI"] = "redis://mock";
}
// TODO: better approach for this when needed
// https://firebase.google.com/docs/rules/unit-tests#run_local_unit_tests_with_the_version_9_javascript_sdk
vi.mock("firebase-admin", () => ({
__esModule: true,
default: {
auth: (): unknown => ({
verifyIdToken: (
_token: string,
_checkRevoked: boolean
): unknown /* Promise<DecodedIdToken> */ =>
Promise.resolve({
aud: "mockFirebaseProjectId",
auth_time: 123,
exp: 1000,
uid: "mockUid",
}),
}),
},
}));
const collectionsForCleanUp = ["users"];
let db: Db;
let client: MongoClient;
const collectionsForCleanUp = ["users"];
beforeAll(async () => {
await MongoDbMock.setup({
serverOptions: {
binary: {
version: "6.0.12",
},
},
//don't add any configuration here, add to global-setup.ts instead.
});
client = new MongoClient(globalThis.__MONGO_URI__);
await client.connect();
db = client.db();
vi.mock("../src/init/db", () => ({
__esModule: true,
getDb: (): Db => db,
collection: <T>(name: string): Collection<WithId<T>> =>
db.collection<WithId<T>>(name),
close: () => {},
}));
vi.mock("../src/utils/logger", () => ({
__esModule: true,
default: {
error: console.error,
warning: console.warn,
info: console.info,
success: console.info,
logToDb: console.info,
},
}));
vi.mock("swagger-stats", () => ({
getMiddleware:
() =>
(_: unknown, __: unknown, next: () => unknown): void => {
next();
},
}));
// TODO: better approach for this when needed
// https://firebase.google.com/docs/rules/unit-tests#run_local_unit_tests_with_the_version_9_javascript_sdk
vi.mock("firebase-admin", () => ({
__esModule: true,
default: {
auth: (): unknown => ({
verifyIdToken: (
_token: string,
_checkRevoked: boolean
): unknown /* Promise<DecodedIdToken> */ =>
Promise.resolve({
aud: "mockFirebaseProjectId",
auth_time: 123,
exp: 1000,
uid: "mockUid",
}),
}),
},
}));
});
beforeEach(async () => {
afterEach(async () => {
if (globalThis.__MONGO_URI__) {
await Promise.all(
collectionsForCleanUp.map((collection) =>
@ -82,13 +81,12 @@ beforeEach(async () => {
}
});
const realDateNow = Date.now;
afterEach(() => {
Date.now = realDateNow;
});
afterAll(async () => {
await client?.close();
await MongoDbMock.teardown();
// @ts-ignore
db = undefined;
//@ts-ignore
client = undefined;
vi.resetAllMocks();
});

View file

@ -2,8 +2,13 @@ import _ from "lodash";
import * as misc from "../../src/utils/misc";
describe("Misc Utils", () => {
afterAll(() => {
vi.useRealTimers();
});
it("getCurrentDayTimestamp", () => {
Date.now = vi.fn(() => 1652743381);
vi.useFakeTimers();
vi.setSystemTime(1652743381);
const currentDay = misc.getCurrentDayTimestamp();
expect(currentDay).toBe(1641600000);

File diff suppressed because it is too large Load diff

View file

@ -81,5 +81,8 @@
"typescript": "5.3.3",
"vitest": "1.6.0",
"vitest-mongodb": "0.0.5"
},
"overrides": {
"mongodb-memory-server": "9.2.0"
}
}

View file

@ -19,8 +19,6 @@ import { deleteConfig } from "../../dal/config";
import { verify } from "../../utils/captcha";
import * as LeaderboardsDAL from "../../dal/leaderboards";
import { purgeUserFromDailyLeaderboards } from "../../utils/daily-leaderboards";
import { randomBytes } from "crypto";
import * as RedisClient from "../../init/redis";
import { v4 as uuidv4 } from "uuid";
import { ObjectId } from "mongodb";
import * as ReportDAL from "../../dal/report";
@ -32,6 +30,7 @@ import {
} from "../../utils/auth";
import * as Dates from "date-fns";
import { UTCDateMini } from "@date-fns/utc";
import * as BlocklistDal from "../../dal/blocklist";
async function verifyCaptcha(captcha: string): Promise<void> {
if (!(await verify(captcha))) {
@ -47,28 +46,30 @@ export async function createNewUser(
try {
await verifyCaptcha(captcha);
} catch (e) {
try {
await firebaseDeleteUser(uid);
} catch (e) {
// user might be deleted on the frontend
if (email.endsWith("@tidal.lol") || email.endsWith("@selfbot.cc")) {
throw new MonkeyError(400, "Invalid domain");
}
const available = await UserDAL.isNameAvailable(name, uid);
if (!available) {
throw new MonkeyError(409, "Username unavailable");
}
const blocklisted = await BlocklistDal.contains({ name, email });
if (blocklisted) {
throw new MonkeyError(409, "Username or email blocked");
}
await UserDAL.addUser(name, email, uid);
void Logger.logToDb("user_created", `${name} ${email}`, uid);
return new MonkeyResponse("User created");
} catch (e) {
//user was created in firebase from the frontend, remove it
await firebaseDeleteUserIgnoreError(uid);
throw e;
}
if (email.endsWith("@tidal.lol") || email.endsWith("@selfbot.cc")) {
throw new MonkeyError(400, "Invalid domain");
}
const available = await UserDAL.isNameAvailable(name, uid);
if (!available) {
throw new MonkeyError(409, "Username unavailable");
}
await UserDAL.addUser(name, email, uid);
void Logger.logToDb("user_created", `${name} ${email}`, uid);
return new MonkeyResponse("User created");
}
export async function sendVerificationEmail(
@ -182,10 +183,9 @@ export async function deleteUser(
const userInfo = await UserDAL.getUser(uid, "delete user");
// gdpr goes brr, find a different way
// if (userInfo.banned) {
// throw new MonkeyError(403, "Banned users cannot delete their account");
// }
if (userInfo.banned === true) {
await BlocklistDal.add(userInfo);
}
//cleanup database
await Promise.all([
@ -435,39 +435,24 @@ export async function getUser(
export async function getOauthLink(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const connection = RedisClient.getConnection();
if (!connection) {
throw new MonkeyError(500, "Redis connection not found");
}
const { uid } = req.ctx.decodedToken;
const token = randomBytes(10).toString("hex");
//add the token uid pair to reids
await connection.setex(`discordoauth:${uid}`, 60, token);
//build the url
const url = DiscordUtils.getOauthLink();
const url = await DiscordUtils.getOauthLink(uid);
//return
return new MonkeyResponse("Discord oauth link generated", {
url: `${url}&state=${token}`,
url: url,
});
}
export async function linkDiscord(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const connection = RedisClient.getConnection();
if (!connection) {
throw new MonkeyError(500, "Redis connection not found");
}
const { uid } = req.ctx.decodedToken;
const { tokenType, accessToken, state } = req.body;
const redisToken = await connection.getdel(`discordoauth:${uid}`);
if (!(redisToken ?? "") || redisToken !== state) {
if (!(await DiscordUtils.iStateValidForUser(state, uid))) {
throw new MonkeyError(403, "Invalid user token");
}
@ -503,6 +488,10 @@ export async function linkDiscord(
);
}
if (await BlocklistDal.contains({ discordId })) {
throw new MonkeyError(409, "The Discord account is blocked");
}
await UserDAL.linkDiscord(uid, discordId, discordAvatar);
await GeorgeQueue.linkDiscord(discordId, uid);
@ -1033,3 +1022,11 @@ export async function getTestActivity(
return new MonkeyResponse("Test activity data retrieved", user.testActivity);
}
async function firebaseDeleteUserIgnoreError(uid: string): Promise<void> {
try {
await firebaseDeleteUser(uid);
} catch (e) {
//ignore
}
}

View file

@ -22,7 +22,6 @@ router.use(
invalidMessage: "Admin endpoints are currently disabled.",
})
);
router.get(
"/",
adminLimit,

View file

@ -0,0 +1,116 @@
import { Collection } from "mongodb";
import * as db from "../init/db";
import { createHash } from "crypto";
type BlocklistEntryProperties = Pick<
SharedTypes.User,
"name" | "email" | "discordId"
>;
// Export for use in tests
export const getCollection = (): Collection<MonkeyTypes.DBBlocklistEntry> =>
db.collection("blocklist");
export async function add(user: BlocklistEntryProperties): Promise<void> {
const timestamp = Date.now();
const inserts: Promise<unknown>[] = [];
const usernameHash = hash(user.name);
const emailHash = hash(user.email);
inserts.push(
getCollection().replaceOne(
{ usernameHash },
{
usernameHash,
timestamp,
},
{ upsert: true }
),
getCollection().replaceOne(
{ emailHash },
{
emailHash,
timestamp,
},
{ upsert: true }
)
);
if (user.discordId !== undefined && user.discordId !== "") {
const discordIdHash = hash(user.discordId);
inserts.push(
getCollection().replaceOne(
{ discordIdHash },
{
discordIdHash,
timestamp,
},
{ upsert: true }
)
);
}
await Promise.all(inserts);
}
export async function remove(
user: Partial<BlocklistEntryProperties>
): Promise<void> {
const filter = getFilter(user);
if (filter.length === 0) return;
await getCollection().deleteMany({ $or: filter });
}
export async function contains(
user: Partial<BlocklistEntryProperties>
): Promise<boolean> {
const filter = getFilter(user);
if (filter.length === 0) return false;
return (
(await getCollection().countDocuments({
$or: filter,
})) !== 0
);
}
export function hash(value: string): string {
return createHash("sha256").update(value.toLocaleLowerCase()).digest("hex");
}
function getFilter(
user: Partial<BlocklistEntryProperties>
): Partial<MonkeyTypes.DBBlocklistEntry>[] {
const filter: Partial<MonkeyTypes.DBBlocklistEntry>[] = [];
if (user.email !== undefined) {
filter.push({ emailHash: hash(user.email) });
}
if (user.name !== undefined) {
filter.push({ usernameHash: hash(user.name) });
}
if (user.discordId !== undefined) {
filter.push({ discordIdHash: hash(user.discordId) });
}
return filter;
}
export async function createIndicies(): Promise<void> {
await getCollection().createIndex(
{ usernameHash: 1 },
{
unique: true,
partialFilterExpression: { usernameHash: { $exists: true } },
}
);
await getCollection().createIndex(
{ emailHash: 1 },
{
unique: true,
partialFilterExpression: { emailHash: { $exists: true } },
}
);
await getCollection().createIndex(
{ discordIdHash: 1 },
{
unique: true,
partialFilterExpression: { discordIdHash: { $exists: true } },
}
);
}

View file

@ -14,6 +14,7 @@ import * as EmailClient from "./init/email-client";
import { init as initFirebaseAdmin } from "./init/firebase-admin";
import { createIndicies as leaderboardDbSetup } from "./dal/leaderboards";
import { createIndicies as blocklistDbSetup } from "./dal/blocklist";
async function bootServer(port: number): Promise<Server> {
try {
@ -68,6 +69,9 @@ async function bootServer(port: number): Promise<Server> {
Logger.info("Setting up leaderboard indicies...");
await leaderboardDbSetup();
Logger.info("Setting up blocklist indicies...");
await blocklistDbSetup();
recordServerVersion(version);
} catch (error) {
Logger.error("Failed to boot server");

View file

@ -110,4 +110,14 @@ declare namespace MonkeyTypes {
type DBResult = MonkeyTypes.WithObjectId<
SharedTypes.DBResult<SharedTypes.Config.Mode>
>;
type BlocklistEntry = {
_id: string;
usernameHash?: string;
emailHash?: string;
discordIdHash?: string;
timestamp: number;
};
type DBBlocklistEntry = WithObjectId<MonkeyTypes.BlocklistEntry>;
}

View file

@ -1,5 +1,8 @@
import fetch from "node-fetch";
import { isDevEnvironment } from "./misc";
import * as RedisClient from "../init/redis";
import { randomBytes } from "crypto";
import MonkeyError from "./error";
const BASE_URL = "https://discord.com/api";
@ -34,10 +37,32 @@ export async function getDiscordUser(
return (await response.json()) as DiscordUser;
}
export function getOauthLink(): string {
export async function getOauthLink(uid: string): Promise<string> {
const connection = RedisClient.getConnection();
if (!connection) {
throw new MonkeyError(500, "Redis connection not found");
}
const token = randomBytes(10).toString("hex");
//add the token uid pair to reids
await connection.setex(`discordoauth:${uid}`, 60, token);
return `${BASE_URL}/oauth2/authorize?client_id=798272335035498557&redirect_uri=${
isDevEnvironment()
? `http%3A%2F%2Flocalhost%3A3000%2Fverify`
: `https%3A%2F%2Fmonkeytype.com%2Fverify`
}&response_type=token&scope=identify`;
}&response_type=token&scope=identify&state=${token}`;
}
export async function iStateValidForUser(
state: string,
uid: string
): Promise<boolean> {
const connection = RedisClient.getConnection();
if (!connection) {
throw new MonkeyError(500, "Redis connection not found");
}
const redisToken = await connection.getdel(`discordoauth:${uid}`);
return redisToken === state;
}

View file

@ -4,6 +4,7 @@ export default defineConfig({
test: {
globals: true,
environment: "node",
globalSetup: "__tests__/global-setup.ts",
setupFiles: ["__tests__/setup-tests.ts"],
pool: "forks",

View file

@ -112,7 +112,7 @@
</p>
<p>Effective date: September 8, 2021</p>
<p>Last updated: Oct 24, 2023</p>
<p>Last updated: May 20, 2024</p>
<p>
Thanks for trusting Monkeytype ('Monkeytype', 'we', 'us', 'our') with
your personal information! We take our responsibility to you very
@ -177,6 +177,7 @@
<ul>
<li>Email</li>
<li>Username</li>
<li>Discord id and discord avatar id (if you provide it)</li>
<li>Information about each typing test</li>
<li>Your currently active settings</li>
<li>How many typing tests you've started and completed</li>
@ -221,6 +222,12 @@
<li>Display leaderboards</li>
</ul>
<p>
If you are found to be cheating or exploiting the website, we may
store hashed versions of your username, email and/or discord id to
prevent you from creating new accounts.
</p>
<h1 id="Data_Storage">How do we store your data?</h1>
<p>Monkeytype securely stores your data using MongoDB.</p>
@ -245,7 +252,10 @@
</li>
<li>
The right to erasure You have the right to request that Monkeytype
erase your personal data, under certain conditions.
erase your personal data, under certain conditions. (Hashed data
mentioned in the "How will we use your data?" section will not be
deleted, as it is essential in preventing the exploitation of the
website)
</li>
<li>
The right to restrict processing You have the right to request