mirror of
https://github.com/spacebarchat/server.git
synced 2026-10-23 06:32:34 +00:00
Implement WebAuthn (#967)
* implement webauthn * code review --------- Co-authored-by: Madeline <46743919+MaddyUnderStars@users.noreply.github.com>
This commit is contained in:
1 parent
e98cdfbce0
commit
709dc7280e
20 files changed
+676
-47
No files matched your search
+8
-1
@@ -19,7 +19,13 @@
|
||||
import "missing-native-js-functions";
|
||||
import { Server, ServerOptions } from "lambert-server";
|
||||
import { Authentication, CORS } from "./middlewares/";
|
||||
import { Config, initDatabase, initEvent, Sentry } from "@fosscord/util";
|
||||
import {
|
||||
Config,
|
||||
initDatabase,
|
||||
initEvent,
|
||||
Sentry,
|
||||
WebAuthn,
|
||||
} from "@fosscord/util";
|
||||
import { ErrorHandler } from "./middlewares/ErrorHandler";
|
||||
import { BodyParser } from "./middlewares/BodyParser";
|
||||
import { Router, Request, Response } from "express";
|
||||
@@ -58,6 +64,7 @@ export class FosscordServer extends Server {
|
||||
await initEvent();
|
||||
await initInstance();
|
||||
await Sentry.init(this.app);
|
||||
WebAuthn.init();
|
||||
|
||||
const logRequests = process.env["LOG_REQUESTS"] != undefined;
|
||||
if (logRequests) {
|
||||
|
||||
@@ -27,6 +27,7 @@ export const NO_AUTHORIZATION_ROUTES = [
|
||||
"/auth/register",
|
||||
"/auth/location-metadata",
|
||||
"/auth/mfa/totp",
|
||||
"/auth/mfa/webauthn",
|
||||
// Routes with a seperate auth system
|
||||
"/webhooks/",
|
||||
// Public information endpoints
|
||||
|
||||
@@ -16,18 +16,20 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Request, Response, Router } from "express";
|
||||
import { route, getIpAdress, verifyCaptcha } from "@fosscord/api";
|
||||
import bcrypt from "bcrypt";
|
||||
import { getIpAdress, route, verifyCaptcha } from "@fosscord/api";
|
||||
import {
|
||||
Config,
|
||||
User,
|
||||
generateToken,
|
||||
adjustEmail,
|
||||
Config,
|
||||
FieldErrors,
|
||||
generateToken,
|
||||
generateWebAuthnTicket,
|
||||
LoginSchema,
|
||||
User,
|
||||
WebAuthn,
|
||||
} from "@fosscord/util";
|
||||
import bcrypt from "bcrypt";
|
||||
import crypto from "crypto";
|
||||
import { Request, Response, Router } from "express";
|
||||
|
||||
const router: Router = Router();
|
||||
export default router;
|
||||
@@ -73,7 +75,10 @@ router.post(
|
||||
"settings",
|
||||
"totp_secret",
|
||||
"mfa_enabled",
|
||||
"webauthn_enabled",
|
||||
"security_keys",
|
||||
],
|
||||
relations: ["security_keys"],
|
||||
}).catch(() => {
|
||||
throw FieldErrors({
|
||||
login: {
|
||||
@@ -116,7 +121,7 @@ router.post(
|
||||
});
|
||||
}
|
||||
|
||||
if (user.mfa_enabled) {
|
||||
if (user.mfa_enabled && !user.webauthn_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");
|
||||
|
||||
@@ -130,6 +135,40 @@ router.post(
|
||||
});
|
||||
}
|
||||
|
||||
if (user.mfa_enabled && user.webauthn_enabled) {
|
||||
if (!WebAuthn.fido2) {
|
||||
// TODO: I did this for typescript and I can't use !
|
||||
throw new Error("WebAuthn not enabled");
|
||||
}
|
||||
|
||||
const options = await WebAuthn.fido2.assertionOptions();
|
||||
const challenge = JSON.stringify({
|
||||
publicKey: {
|
||||
...options,
|
||||
challenge: Buffer.from(options.challenge).toString(
|
||||
"base64",
|
||||
),
|
||||
allowCredentials: user.security_keys.map((x) => ({
|
||||
id: x.key_id,
|
||||
type: "public-key",
|
||||
})),
|
||||
transports: ["usb", "ble", "nfc"],
|
||||
timeout: 60000,
|
||||
},
|
||||
});
|
||||
|
||||
const ticket = await generateWebAuthnTicket(challenge);
|
||||
await User.update({ id: user.id }, { totp_last_ticket: ticket });
|
||||
|
||||
return res.json({
|
||||
ticket: ticket,
|
||||
mfa: true,
|
||||
sms: false, // TODO
|
||||
token: null,
|
||||
webauthn: challenge,
|
||||
});
|
||||
}
|
||||
|
||||
const token = await generateToken(user.id);
|
||||
|
||||
// Notice this will have a different token structure, than discord
|
||||
@@ -147,6 +186,9 @@ router.post(
|
||||
* MFA required:
|
||||
* @returns {"token": null, "mfa": true, "sms": true, "ticket": "SOME TICKET JWT TOKEN"}
|
||||
|
||||
* WebAuthn MFA required:
|
||||
* @returns {"token": null, "mfa": true, "webauthn": true, "sms": true, "ticket": "SOME TICKET JWT TOKEN"}
|
||||
|
||||
* Captcha required:
|
||||
* @returns {"captcha_key": ["captcha-required"], "captcha_sitekey": null, "captcha_service": "recaptcha"}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
Fosscord: A FOSS re-implementation and extension of the Discord.com backend.
|
||||
Copyright (C) 2023 Fosscord and Fosscord Contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { route } from "@fosscord/api";
|
||||
import {
|
||||
generateToken,
|
||||
SecurityKey,
|
||||
User,
|
||||
verifyWebAuthnToken,
|
||||
WebAuthn,
|
||||
WebAuthnTotpSchema,
|
||||
} from "@fosscord/util";
|
||||
import { Request, Response, Router } from "express";
|
||||
import { ExpectedAssertionResult } from "fido2-lib";
|
||||
import { HTTPError } from "lambert-server";
|
||||
const router = Router();
|
||||
|
||||
function toArrayBuffer(buf: Buffer) {
|
||||
const ab = new ArrayBuffer(buf.length);
|
||||
const view = new Uint8Array(ab);
|
||||
for (let i = 0; i < buf.length; ++i) {
|
||||
view[i] = buf[i];
|
||||
}
|
||||
return ab;
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
route({ body: "WebAuthnTotpSchema" }),
|
||||
async (req: Request, res: Response) => {
|
||||
if (!WebAuthn.fido2) {
|
||||
// TODO: I did this for typescript and I can't use !
|
||||
throw new Error("WebAuthn not enabled");
|
||||
}
|
||||
|
||||
const { code, ticket } = req.body as WebAuthnTotpSchema;
|
||||
|
||||
const user = await User.findOneOrFail({
|
||||
where: {
|
||||
totp_last_ticket: ticket,
|
||||
},
|
||||
select: ["id", "settings"],
|
||||
});
|
||||
|
||||
const ret = await verifyWebAuthnToken(ticket);
|
||||
if (!ret)
|
||||
throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008);
|
||||
|
||||
await User.update({ id: user.id }, { totp_last_ticket: "" });
|
||||
|
||||
const clientAttestationResponse = JSON.parse(code);
|
||||
const securityKey = await SecurityKey.findOneOrFail({
|
||||
where: {
|
||||
user_id: req.user_id,
|
||||
key_id: clientAttestationResponse.rawId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!clientAttestationResponse.rawId)
|
||||
throw new HTTPError("Missing rawId", 400);
|
||||
|
||||
clientAttestationResponse.rawId = toArrayBuffer(
|
||||
Buffer.from(clientAttestationResponse.rawId, "base64"),
|
||||
);
|
||||
|
||||
const assertionExpectations: ExpectedAssertionResult = JSON.parse(
|
||||
Buffer.from(
|
||||
clientAttestationResponse.response.clientDataJSON,
|
||||
"base64",
|
||||
).toString(),
|
||||
);
|
||||
|
||||
const authnResult = await WebAuthn.fido2.assertionResult(
|
||||
clientAttestationResponse,
|
||||
{
|
||||
...assertionExpectations,
|
||||
factor: "second",
|
||||
publicKey: securityKey.public_key,
|
||||
prevCounter: securityKey.counter,
|
||||
userHandle: securityKey.key_id,
|
||||
},
|
||||
);
|
||||
|
||||
const counter = authnResult.authnrData.get("counter");
|
||||
|
||||
securityKey.counter = counter;
|
||||
|
||||
await securityKey.save();
|
||||
|
||||
return res.json({
|
||||
token: await generateToken(user.id),
|
||||
user_settings: user.settings,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
Fosscord: A FOSS re-implementation and extension of the Discord.com backend.
|
||||
Copyright (C) 2023 Fosscord and Fosscord Contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { route } from "@fosscord/api";
|
||||
import { SecurityKey } from "@fosscord/util";
|
||||
import { Request, Response, Router } from "express";
|
||||
const router = Router();
|
||||
|
||||
router.delete("/", route({}), async (req: Request, res: Response) => {
|
||||
const { key_id } = req.params;
|
||||
|
||||
await SecurityKey.delete({
|
||||
id: key_id,
|
||||
user_id: req.user_id,
|
||||
});
|
||||
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
Fosscord: A FOSS re-implementation and extension of the Discord.com backend.
|
||||
Copyright (C) 2023 Fosscord and Fosscord Contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { route } from "@fosscord/api";
|
||||
import {
|
||||
CreateWebAuthnCredentialSchema,
|
||||
DiscordApiErrors,
|
||||
FieldErrors,
|
||||
GenerateWebAuthnCredentialsSchema,
|
||||
generateWebAuthnTicket,
|
||||
SecurityKey,
|
||||
User,
|
||||
verifyWebAuthnToken,
|
||||
WebAuthn,
|
||||
WebAuthnPostSchema,
|
||||
} from "@fosscord/util";
|
||||
import bcrypt from "bcrypt";
|
||||
import { Request, Response, Router } from "express";
|
||||
import { ExpectedAttestationResult } from "fido2-lib";
|
||||
import { HTTPError } from "lambert-server";
|
||||
const router = Router();
|
||||
|
||||
const isGenerateSchema = (
|
||||
body: WebAuthnPostSchema,
|
||||
): body is GenerateWebAuthnCredentialsSchema => {
|
||||
return "password" in body;
|
||||
};
|
||||
|
||||
const isCreateSchema = (
|
||||
body: WebAuthnPostSchema,
|
||||
): body is CreateWebAuthnCredentialSchema => {
|
||||
return "credential" in body;
|
||||
};
|
||||
|
||||
function toArrayBuffer(buf: Buffer) {
|
||||
const ab = new ArrayBuffer(buf.length);
|
||||
const view = new Uint8Array(ab);
|
||||
for (let i = 0; i < buf.length; ++i) {
|
||||
view[i] = buf[i];
|
||||
}
|
||||
return ab;
|
||||
}
|
||||
|
||||
router.get("/", route({}), async (req: Request, res: Response) => {
|
||||
const securityKeys = await SecurityKey.find({
|
||||
where: {
|
||||
user_id: req.user_id,
|
||||
},
|
||||
});
|
||||
|
||||
return res.json(
|
||||
securityKeys.map((key) => ({
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
route({ body: "WebAuthnPostSchema" }),
|
||||
async (req: Request, res: Response) => {
|
||||
if (!WebAuthn.fido2) {
|
||||
// TODO: I did this for typescript and I can't use !
|
||||
throw new Error("WebAuthn not enabled");
|
||||
}
|
||||
|
||||
const user = await User.findOneOrFail({
|
||||
where: {
|
||||
id: req.user_id,
|
||||
},
|
||||
select: [
|
||||
"data",
|
||||
"id",
|
||||
"disabled",
|
||||
"deleted",
|
||||
"settings",
|
||||
"totp_secret",
|
||||
"mfa_enabled",
|
||||
"username",
|
||||
],
|
||||
});
|
||||
|
||||
if (isGenerateSchema(req.body)) {
|
||||
const { password } = req.body;
|
||||
const same_password = await bcrypt.compare(
|
||||
password,
|
||||
user.data.hash || "",
|
||||
);
|
||||
if (!same_password) {
|
||||
throw FieldErrors({
|
||||
password: {
|
||||
message: req.t("auth:login.INVALID_PASSWORD"),
|
||||
code: "INVALID_PASSWORD",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const registrationOptions =
|
||||
await WebAuthn.fido2.attestationOptions();
|
||||
const challenge = JSON.stringify({
|
||||
publicKey: {
|
||||
...registrationOptions,
|
||||
challenge: Buffer.from(
|
||||
registrationOptions.challenge,
|
||||
).toString("base64"),
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.username,
|
||||
displayName: user.username,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const ticket = await generateWebAuthnTicket(challenge);
|
||||
|
||||
return res.json({
|
||||
ticket: ticket,
|
||||
challenge,
|
||||
});
|
||||
} else if (isCreateSchema(req.body)) {
|
||||
const { credential, name, ticket } = req.body;
|
||||
|
||||
const verified = await verifyWebAuthnToken(ticket);
|
||||
if (!verified) throw new HTTPError("Invalid ticket", 400);
|
||||
|
||||
const clientAttestationResponse = JSON.parse(credential);
|
||||
|
||||
if (!clientAttestationResponse.rawId)
|
||||
throw new HTTPError("Missing rawId", 400);
|
||||
|
||||
const rawIdBuffer = Buffer.from(
|
||||
clientAttestationResponse.rawId,
|
||||
"base64",
|
||||
);
|
||||
clientAttestationResponse.rawId = toArrayBuffer(rawIdBuffer);
|
||||
|
||||
const attestationExpectations: ExpectedAttestationResult =
|
||||
JSON.parse(
|
||||
Buffer.from(
|
||||
clientAttestationResponse.response.clientDataJSON,
|
||||
"base64",
|
||||
).toString(),
|
||||
);
|
||||
|
||||
const regResult = await WebAuthn.fido2.attestationResult(
|
||||
clientAttestationResponse,
|
||||
{
|
||||
...attestationExpectations,
|
||||
factor: "second",
|
||||
},
|
||||
);
|
||||
|
||||
const authnrData = regResult.authnrData;
|
||||
const keyId = Buffer.from(authnrData.get("credId")).toString(
|
||||
"base64",
|
||||
);
|
||||
const counter = authnrData.get("counter");
|
||||
const publicKey = authnrData.get("credentialPublicKeyPem");
|
||||
|
||||
const securityKey = SecurityKey.create({
|
||||
name,
|
||||
counter,
|
||||
public_key: publicKey,
|
||||
user_id: req.user_id,
|
||||
key_id: keyId,
|
||||
});
|
||||
|
||||
await securityKey.save();
|
||||
|
||||
return res.json({
|
||||
name,
|
||||
id: securityKey.id,
|
||||
});
|
||||
} else {
|
||||
throw DiscordApiErrors.INVALID_AUTHENTICATION_TOKEN;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
Reference in new issue
Block a user