mirror of
https://github.com/spacebarchat/server.git
synced 2024-11-13 06:02:39 +01:00
2fa
This commit is contained in:
parent
469e55fffa
commit
5cdcc48d1b
File diff suppressed because it is too large
Load Diff
@ -2,7 +2,9 @@
|
|||||||
"login": {
|
"login": {
|
||||||
"INVALID_LOGIN": "E-Mail or Phone not found",
|
"INVALID_LOGIN": "E-Mail or Phone not found",
|
||||||
"INVALID_PASSWORD": "Invalid Password",
|
"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": {
|
"register": {
|
||||||
"REGISTRATION_DISABLED": "New user registration is disabled",
|
"REGISTRATION_DISABLED": "New user registration is disabled",
|
||||||
|
@ -7,6 +7,7 @@ export const NO_AUTHORIZATION_ROUTES = [
|
|||||||
"/auth/login",
|
"/auth/login",
|
||||||
"/auth/register",
|
"/auth/register",
|
||||||
"/auth/location-metadata",
|
"/auth/location-metadata",
|
||||||
|
"/auth/mfa/totp",
|
||||||
// Routes with a seperate auth system
|
// Routes with a seperate auth system
|
||||||
"/webhooks/",
|
"/webhooks/",
|
||||||
// Public information endpoints
|
// Public information endpoints
|
||||||
|
@ -2,6 +2,7 @@ import { Request, Response, Router } from "express";
|
|||||||
import { route } from "@fosscord/api";
|
import { route } from "@fosscord/api";
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import { Config, User, generateToken, adjustEmail, FieldErrors } from "@fosscord/util";
|
import { Config, User, generateToken, adjustEmail, FieldErrors } from "@fosscord/util";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
const router: Router = Router();
|
const router: Router = Router();
|
||||||
export default router;
|
export default router;
|
||||||
@ -37,7 +38,7 @@ router.post("/", route({ body: "LoginSchema" }), async (req: Request, res: Respo
|
|||||||
|
|
||||||
const user = await User.findOneOrFail({
|
const user = await User.findOneOrFail({
|
||||||
where: [{ phone: login }, { email: login }],
|
where: [{ phone: login }, { email: login }],
|
||||||
select: ["data", "id", "disabled", "deleted", "settings"]
|
select: ["data", "id", "disabled", "deleted", "settings", "totp_secret", "mfa_enabled"]
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
throw FieldErrors({ login: { message: req.t("auth:login.INVALID_LOGIN"), code: "INVALID_LOGIN" } });
|
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" } });
|
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);
|
const token = await generateToken(user.id);
|
||||||
|
|
||||||
// Notice this will have a different token structure, than discord
|
// Notice this will have a different token structure, than discord
|
||||||
|
49
api/src/routes/auth/mfa/totp.ts
Normal file
49
api/src/routes/auth/mfa/totp.ts
Normal 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;
|
48
api/src/routes/users/@me/mfa/codes.ts
Normal file
48
api/src/routes/users/@me/mfa/codes.ts
Normal 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;
|
45
api/src/routes/users/@me/mfa/totp/disable.ts
Normal file
45
api/src/routes/users/@me/mfa/totp/disable.ts
Normal 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;
|
51
api/src/routes/users/@me/mfa/totp/enable.ts
Normal file
51
api/src/routes/users/@me/mfa/totp/enable.ts
Normal 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;
|
35
util/src/entities/BackupCodes.ts
Normal file
35
util/src/entities/BackupCodes.ts
Normal 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;
|
||||||
|
}
|
@ -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 { BaseClass } from "./BaseClass";
|
||||||
import { BitField } from "../util/BitField";
|
import { BitField } from "../util/BitField";
|
||||||
import { Relationship } from "./Relationship";
|
import { Relationship } from "./Relationship";
|
||||||
@ -108,6 +108,12 @@ export class User extends BaseClass {
|
|||||||
@Column({ select: false })
|
@Column({ select: false })
|
||||||
mfa_enabled: boolean; // if multi factor authentication is enabled
|
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()
|
@Column()
|
||||||
created_at: Date; // registration date
|
created_at: Date; // registration date
|
||||||
|
|
||||||
|
@ -27,4 +27,5 @@ export * from "./Template";
|
|||||||
export * from "./User";
|
export * from "./User";
|
||||||
export * from "./VoiceState";
|
export * from "./VoiceState";
|
||||||
export * from "./Webhook";
|
export * from "./Webhook";
|
||||||
export * from "./ClientRelease";
|
export * from "./ClientRelease";
|
||||||
|
export * from "./BackupCodes";
|
Loading…
Reference in New Issue
Block a user