Merge branch '2fa' into feat/latestWebClient

Add placeholder codes-verification and view-backup-codes-challenge routes
This commit is contained in:
Madeline 2022-07-20 20:48:07 +10:00
commit 3bbaa1a08b
20 changed files with 2327 additions and 18 deletions

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,9 @@
"login": {
"INVALID_LOGIN": "E-Mail or Phone not found",
"INVALID_PASSWORD": "Invalid Password",
"ACCOUNT_DISABLED": "This account is disabled"
"ACCOUNT_DISABLED": "This account is disabled",
"INVALID_TOTP_CODE": "Invalid two-factor code.",
"INVALID_TOTP_SECRET": "Invalid two-factor secret."
},
"register": {
"REGISTRATION_DISABLED": "New user registration is disabled",

View File

@ -5,14 +5,14 @@
"main": "dist/index.js",
"types": "src/index.ts",
"scripts": {
"test:only": "jest --coverage --verbose --forceExit ./tests",
"test:routes": "jest --coverage --verbose --forceExit ./routes.test.ts",
"test:only": "npx jest --coverage --verbose --forceExit ./tests",
"test:routes": "npx jest --coverage --verbose --forceExit ./routes.test.ts",
"test": "npm run build && npm run test:only",
"test:watch": "jest --watch",
"test:watch": "npx jest --watch",
"start": "npm run build && node dist/start",
"build": "npx tsc -p .",
"dev": "tsnd --respawn src/start.ts",
"patch": "ts-patch install -s && npx patch-package",
"dev": "npx tsnd --respawn src/start.ts",
"patch": "npx ts-patch install -s && npx patch-package",
"postinstall": "npm run patch",
"generate:docs": "node scripts/generate_openapi",
"generate:schema": "node scripts/generate_schema"

View File

@ -7,6 +7,7 @@ export const NO_AUTHORIZATION_ROUTES = [
"/auth/login",
"/auth/register",
"/auth/location-metadata",
"/auth/mfa/totp",
// Routes with a seperate auth system
"/webhooks/",
// Public information endpoints

View File

@ -2,6 +2,7 @@ import { Request, Response, Router } from "express";
import { route } from "@fosscord/api";
import bcrypt from "bcrypt";
import { Config, User, generateToken, adjustEmail, FieldErrors } from "@fosscord/util";
import crypto from "crypto";
const router: Router = Router();
export default router;
@ -37,7 +38,7 @@ router.post("/", route({ body: "LoginSchema" }), async (req: Request, res: Respo
const user = await User.findOneOrFail({
where: [{ phone: login }, { email: login }],
select: ["data", "id", "disabled", "deleted", "settings"]
select: ["data", "id", "disabled", "deleted", "settings", "totp_secret", "mfa_enabled"]
}).catch((e) => {
throw FieldErrors({ login: { message: req.t("auth:login.INVALID_LOGIN"), code: "INVALID_LOGIN" } });
});
@ -57,6 +58,20 @@ router.post("/", route({ body: "LoginSchema" }), async (req: Request, res: Respo
throw FieldErrors({ password: { message: req.t("auth:login.INVALID_PASSWORD"), code: "INVALID_PASSWORD" } });
}
if (user.mfa_enabled) {
// TODO: This is not a discord.com ticket. I'm not sure what it is but I'm lazy
const ticket = crypto.randomBytes(40).toString("hex");
await User.update({ id: user.id }, { totp_last_ticket: ticket });
return res.json({
ticket: ticket,
mfa: true,
sms: false, // TODO
token: null,
})
}
const token = await generateToken(user.id);
// Notice this will have a different token structure, than discord

View File

@ -0,0 +1,49 @@
import { Router, Request, Response } from "express";
import { route } from "@fosscord/api";
import { BackupCode, FieldErrors, generateToken, User } from "@fosscord/util";
import { verifyToken } from "node-2fa";
import { HTTPError } from "lambert-server";
const router = Router();
export interface TotpSchema {
code: string,
ticket: string,
gift_code_sku_id?: string | null,
login_source?: string | null,
}
router.post("/", route({ body: "TotpSchema" }), async (req: Request, res: Response) => {
const { code, ticket, gift_code_sku_id, login_source } = req.body as TotpSchema;
const user = await User.findOneOrFail({
where: {
totp_last_ticket: ticket,
},
select: [
"id",
"totp_secret",
"settings",
],
});
const backup = await BackupCode.findOne({ code: code, expired: false, consumed: false, user: { id: user.id }});
if (!backup) {
const ret = verifyToken(user.totp_secret!, code);
if (!ret || ret.delta != 0)
throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008);
}
else {
backup.consumed = true;
await backup.save();
}
await User.update({ id: user.id }, { totp_last_ticket: "" });
return res.json({
token: await generateToken(user.id),
user_settings: user.settings,
});
});
export default router;

View File

@ -0,0 +1,26 @@
import { Router, Request, Response } from "express";
import { route } from "@fosscord/api";
import { FieldErrors, User } from "@fosscord/util";
import bcrypt from "bcrypt";
const router = Router();
export interface BackupCodesChallengeSchema {
password: string;
}
router.post("/", route({ body: "BackupCodesChallengeSchema" }), async (req: Request, res: Response) => {
const { password } = req.body as BackupCodesChallengeSchema;
const user = await User.findOneOrFail({ id: req.user_id }, { select: ["data"] });
if (!await bcrypt.compare(password, user.data.hash || "")) {
throw FieldErrors({ password: { message: req.t("auth:login.INVALID_PASSWORD"), code: "INVALID_PASSWORD" } });
}
return res.json({
nonce: "NoncePlaceholder",
regenerate_nonce: "RegenNoncePlaceholder",
})
});
export default router;

View File

@ -0,0 +1,45 @@
import { Router, Request, Response } from "express";
import { route } from "@fosscord/api";
import { BackupCode, generateMfaBackupCodes, User } from "@fosscord/util";
const router = Router();
export interface CodesVerificationSchema {
key: string;
nonce: string;
regenerate?: boolean;
}
router.post("/", route({ body: "CodesVerificationSchema" }), async (req: Request, res: Response) => {
const { key, nonce, regenerate } = req.body as CodesVerificationSchema;
// TODO: We don't have email/etc etc, so can't send a verification code.
// Once that's done, this route can verify `key`
const user = await User.findOneOrFail({ id: req.user_id });
var codes: BackupCode[];
if (regenerate) {
await BackupCode.update(
{ user: { id: req.user_id } },
{ expired: true }
);
codes = generateMfaBackupCodes(req.user_id);
await Promise.all(codes.map(x => x.save()));
}
else {
codes = await BackupCode.find({
user: {
id: req.user_id,
},
expired: false,
});
}
return res.json({
backup_codes: codes.map(x => ({ ...x, expired: undefined })),
})
});
export default router;

View File

@ -0,0 +1,48 @@
import { Router, Request, Response } from "express";
import { route } from "@fosscord/api";
import { BackupCode, FieldErrors, generateMfaBackupCodes, User } from "@fosscord/util";
import bcrypt from "bcrypt";
const router = Router();
export interface MfaCodesSchema {
password: string;
regenerate?: boolean;
}
// TODO: This route is replaced with users/@me/mfa/codes-verification in newer clients
router.post("/", route({ body: "MfaCodesSchema" }), async (req: Request, res: Response) => {
const { password, regenerate } = req.body as MfaCodesSchema;
const user = await User.findOneOrFail({ id: req.user_id }, { select: ["data"] });
if (!await bcrypt.compare(password, user.data.hash || "")) {
throw FieldErrors({ password: { message: req.t("auth:login.INVALID_PASSWORD"), code: "INVALID_PASSWORD" } });
}
var codes: BackupCode[];
if (regenerate) {
await BackupCode.update(
{ user: { id: req.user_id } },
{ expired: true }
);
codes = generateMfaBackupCodes(req.user_id);
await Promise.all(codes.map(x => x.save()));
}
else {
codes = await BackupCode.find({
user: {
id: req.user_id,
},
expired: false,
});
}
return res.json({
backup_codes: codes.map(x => ({ ...x, expired: undefined })),
})
});
export default router;

View File

@ -0,0 +1,45 @@
import { Router, Request, Response } from "express";
import { route } from "@fosscord/api";
import { verifyToken } from 'node-2fa';
import { HTTPError } from "lambert-server";
import { User, generateToken, BackupCode } from "@fosscord/util";
const router = Router();
export interface TotpDisableSchema {
code: string;
}
router.post("/", route({ body: "TotpDisableSchema" }), async (req: Request, res: Response) => {
const body = req.body as TotpDisableSchema;
const user = await User.findOneOrFail({ id: req.user_id }, { select: ["totp_secret"] });
const backup = await BackupCode.findOne({ code: body.code });
if (!backup) {
const ret = verifyToken(user.totp_secret!, body.code);
if (!ret || ret.delta != 0)
throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008);
}
await User.update(
{ id: req.user_id },
{
mfa_enabled: false,
totp_secret: "",
},
);
await BackupCode.update(
{ user: { id: req.user_id } },
{
expired: true,
}
);
return res.json({
token: await generateToken(user.id),
});
});
export default router;

View File

@ -0,0 +1,51 @@
import { Router, Request, Response } from "express";
import { User, generateToken, BackupCode, generateMfaBackupCodes } from "@fosscord/util";
import { route } from "@fosscord/api";
import bcrypt from "bcrypt";
import { HTTPError } from "lambert-server";
import { verifyToken } from 'node-2fa';
import crypto from "crypto";
const router = Router();
export interface TotpEnableSchema {
password: string;
code?: string;
secret?: string;
}
router.post("/", route({ body: "TotpEnableSchema" }), async (req: Request, res: Response) => {
const body = req.body as TotpEnableSchema;
const user = await User.findOneOrFail({ where: { id: req.user_id }, select: ["data"] });
// TODO: Are guests allowed to enable 2fa?
if (user.data.hash) {
if (!await bcrypt.compare(body.password, user.data.hash)) {
throw new HTTPError(req.t("auth:login.INVALID_PASSWORD"));
}
}
if (!body.secret)
throw new HTTPError(req.t("auth:login.INVALID_TOTP_SECRET"), 60005);
if (!body.code)
throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008);
if (verifyToken(body.secret, body.code)?.delta != 0)
throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008);
let backup_codes = generateMfaBackupCodes(req.user_id);
await Promise.all(backup_codes.map(x => x.save()));
await User.update(
{ id: req.user_id },
{ mfa_enabled: true, totp_secret: body.secret }
);
res.send({
token: await generateToken(user.id),
backup_codes: backup_codes.map(x => ({ ...x, expired: undefined })),
});
});
export default router;

View File

@ -4,7 +4,7 @@
"description": "",
"main": "src/start.js",
"scripts": {
"setup": "node scripts/install.js && npm install --no-optional && ts-patch install -s && patch-package --patch-dir ../api/patches/ && npm run build",
"setup": "node scripts/install.js && npm install --no-optional && npx ts-patch install -s && npx patch-package --patch-dir ../api/patches/ && npm run build",
"build": "node scripts/build.js",
"start": "node scripts/build.js && node dist/bundle/src/start.js",
"start:bundle": "node dist/bundle/src/start.js",
@ -111,4 +111,4 @@
"typescript-json-schema": "^0.50.1",
"ws": "^7.4.2"
}
}
}

View File

@ -5,7 +5,7 @@
"main": "dist/index.js",
"types": "src/index.ts",
"scripts": {
"test": "npm run build && jest --coverage ./tests",
"test": "npm run build && npx jest --coverage ./tests",
"build": "npx tsc -p .",
"start": "node dist/start.js"
},

View File

@ -5,7 +5,7 @@
"main": "dist/index.js",
"types": "src/index.ts",
"scripts": {
"test": "npm run build && jest --coverage ./tests",
"test": "npm run build && npx jest --coverage ./tests",
"build": "npx tsc -p .",
"start": "node dist/start.js"
},

View File

@ -9,7 +9,7 @@
"test": "echo \"Error: no test specified\" && exit 1",
"start": "npm run build && node dist/start.js",
"build": "npx tsc -p .",
"dev": "tsnd --respawn src/start.ts"
"dev": "npx tsnd --respawn src/start.ts"
},
"keywords": [],
"author": "Fosscord",

View File

@ -42,6 +42,8 @@ async function getMembers(guild_id: string, range: [number, number]) {
.flat()
.unique((r: Role) => r.id);
const offlineItems = [];
for (const role of member_roles) {
// @ts-ignore
const [role_members, other_members] = partition(members, (m: Member) =>
@ -63,7 +65,7 @@ async function getMembers(guild_id: string, range: [number, number]) {
const session = member.user.sessions.first();
// TODO: properly mock/hide offline/invisible status
items.push({
const item = {
member: {
...member,
roles,
@ -74,16 +76,35 @@ async function getMembers(guild_id: string, range: [number, number]) {
user: { id: member.user.id },
},
},
});
}
if (!member?.user?.sessions || !member.user.sessions.length) {
offlineItems.push(item);
group.count--;
continue;
}
items.push(item);
}
members = other_members;
}
if (offlineItems.length) {
const group = {
count: offlineItems.length,
id: "offline",
};
items.push({ group });
groups.push(group);
items.push(...offlineItems);
}
return {
items,
groups,
range,
members: items.map((x) => x.member).filter((x) => x),
members: items.map((x) => 'member' in x ? x.member : undefined).filter(x => !!x),
};
}

View File

@ -6,7 +6,7 @@
"types": "src/index.ts",
"scripts": {
"start": "npm run build && node dist/",
"test": "npm run build && jest",
"test": "npm run build && npx jest",
"postinstall": "npm run build",
"build": "npx tsc -p .",
"typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js"

View File

@ -0,0 +1,35 @@
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { User } from "./User";
import crypto from "crypto";
@Entity("backup_codes")
export class BackupCode extends BaseClass {
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, { onDelete: "CASCADE" })
user: User;
@Column()
code: string;
@Column()
consumed: boolean;
@Column()
expired: boolean;
}
export function generateMfaBackupCodes(user_id: string) {
let backup_codes: BackupCode[] = [];
for (let i = 0; i < 10; i++) {
const code = BackupCode.create({
user: { id: user_id },
code: crypto.randomBytes(4).toString("hex"), // 8 characters
consumed: false,
expired: false,
});
backup_codes.push(code);
}
return backup_codes;
}

View File

@ -1,4 +1,4 @@
import { Column, Entity, FindOneOptions, JoinColumn, ManyToMany, OneToMany, RelationId } from "typeorm";
import { Column, Entity, FindOneOptions, JoinColumn, OneToMany } from "typeorm";
import { BaseClass } from "./BaseClass";
import { BitField } from "../util/BitField";
import { Relationship } from "./Relationship";
@ -108,6 +108,12 @@ export class User extends BaseClass {
@Column({ select: false })
mfa_enabled: boolean; // if multi factor authentication is enabled
@Column({ select: false, nullable: true })
totp_secret?: string;
@Column({ nullable: true, select: false })
totp_last_ticket?: string;
@Column()
created_at: Date; // registration date

View File

@ -27,4 +27,5 @@ export * from "./Template";
export * from "./User";
export * from "./VoiceState";
export * from "./Webhook";
export * from "./ClientRelease";
export * from "./ClientRelease";
export * from "./BackupCodes";