use discord token format
This commit is contained in:
parent
818358cc68
commit
d01b924854
BIN
package-lock.json
generated
BIN
package-lock.json
generated
Binary file not shown.
@ -74,7 +74,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.936.0",
|
||||
"@spacebarchat/medooze-webrtc": "^1.0.8",
|
||||
"@toondepauw/node-zstd": "^1.2.0",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"ajv": "^8.17.1",
|
||||
|
||||
@ -1,196 +1,111 @@
|
||||
/*
|
||||
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
|
||||
Copyright (C) 2023 Spacebar and Spacebar 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 jwt, { VerifyOptions } from "jsonwebtoken";
|
||||
import { Config } from "./Config";
|
||||
import { User } from "../entities";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
// TODO: dont use deprecated APIs lol
|
||||
import { User } from "../entities";
|
||||
import { Config } from "./Config";
|
||||
import {
|
||||
FindOptionsRelationByString,
|
||||
FindOptionsSelectByString,
|
||||
} from "typeorm";
|
||||
import * as console from "node:console";
|
||||
|
||||
export const JWTOptions: VerifyOptions = { algorithms: ["HS256"] };
|
||||
const DISCORD_EPOCH = 1293840000;
|
||||
|
||||
export type UserTokenData = {
|
||||
user: User;
|
||||
decoded: { id: string; iat: number };
|
||||
decoded: {
|
||||
id: string;
|
||||
iat: number;
|
||||
};
|
||||
};
|
||||
|
||||
function logAuth(text: string) {
|
||||
if(process.env.LOG_AUTH !== "true") return;
|
||||
console.log(`[AUTH] ${text}`);
|
||||
function b64url(input: Buffer | string) {
|
||||
return Buffer.from(input)
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
}
|
||||
|
||||
function rejectAndLog(rejectFunction: (reason?: any) => void, reason: any) {
|
||||
console.error(reason);
|
||||
rejectFunction(reason);
|
||||
function b64urlDecode(input: string) {
|
||||
input = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
return Buffer.from(input, "base64").toString();
|
||||
}
|
||||
|
||||
export const checkToken = (
|
||||
function sign(data: string) {
|
||||
return crypto
|
||||
.createHmac("sha256", Config.get().security.jwtSecret)
|
||||
.update(data)
|
||||
.digest();
|
||||
}
|
||||
|
||||
export function generateToken(userId: string): string {
|
||||
const unixNow = Math.floor(Date.now() / 1000);
|
||||
const iat = unixNow - DISCORD_EPOCH;
|
||||
|
||||
const p1 = b64url(userId);
|
||||
const p2 = b64url(iat.toString());
|
||||
|
||||
const sig = b64url(sign(`${p1}.${p2}`));
|
||||
|
||||
return `${p1}.${p2}.${sig}`;
|
||||
}
|
||||
|
||||
export async function checkToken(
|
||||
token: string,
|
||||
opts?: {
|
||||
select?: FindOptionsSelectByString<User>;
|
||||
relations?: FindOptionsRelationByString;
|
||||
},
|
||||
): Promise<UserTokenData> =>
|
||||
new Promise((resolve, reject) => {
|
||||
token = token.replace("Bot ", ""); // there is no bot distinction in sb
|
||||
token = token.replace("Bearer ", ""); // allow bearer tokens
|
||||
): Promise<UserTokenData> {
|
||||
token = token.replace("Bot ", "").replace("Bearer ", "");
|
||||
|
||||
const validateUser: jwt.VerifyCallback = async (err, out) => {
|
||||
const decoded = out as UserTokenData["decoded"];
|
||||
if (err || !decoded) {
|
||||
logAuth("validateUser rejected: " + err);
|
||||
return rejectAndLog(reject, "Invalid Token meow " + err);
|
||||
}
|
||||
|
||||
const user = await User.findOne({
|
||||
where: { id: decoded.id },
|
||||
select: [
|
||||
...(opts?.select || []),
|
||||
"id",
|
||||
"bot",
|
||||
"disabled",
|
||||
"deleted",
|
||||
"rights",
|
||||
"data",
|
||||
],
|
||||
relations: opts?.relations,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
logAuth("validateUser rejected: User not found");
|
||||
return rejectAndLog(reject, "User not found");
|
||||
}
|
||||
|
||||
// we need to round it to seconds as it saved as seconds in jwt iat and valid_tokens_since is stored in milliseconds
|
||||
if (
|
||||
decoded.iat * 1000 <
|
||||
new Date(user.data.valid_tokens_since).setSeconds(0, 0)
|
||||
) {
|
||||
logAuth("validateUser rejected: Token not yet valid");
|
||||
return rejectAndLog(reject, "Invalid Token");
|
||||
}
|
||||
|
||||
if (user.disabled) {
|
||||
logAuth("validateUser rejected: User disabled");
|
||||
return rejectAndLog(reject, "User disabled");
|
||||
}
|
||||
if (user.deleted) {
|
||||
logAuth("validateUser rejected: User deleted");
|
||||
return rejectAndLog(reject, "User not found");
|
||||
}
|
||||
|
||||
logAuth("validateUser success: " + JSON.stringify({ decoded, user }));
|
||||
return resolve({ decoded, user });
|
||||
};
|
||||
|
||||
const dec = jwt.decode(token, { complete: true });
|
||||
if (!dec) return reject("Could not parse token");
|
||||
logAuth("Decoded token: " + JSON.stringify(dec));
|
||||
|
||||
if (dec.header.alg == "HS256") {
|
||||
jwt.verify(
|
||||
token,
|
||||
Config.get().security.jwtSecret,
|
||||
JWTOptions,
|
||||
validateUser,
|
||||
);
|
||||
} else if (dec.header.alg == "ES512") {
|
||||
loadOrGenerateKeypair().then((keyPair) => {
|
||||
jwt.verify(
|
||||
token,
|
||||
keyPair.publicKey,
|
||||
{ algorithms: ["ES512"] },
|
||||
validateUser,
|
||||
);
|
||||
});
|
||||
} else return reject("Invalid token algorithm");
|
||||
});
|
||||
|
||||
export async function generateToken(id: string) {
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const keyPair = await loadOrGenerateKeypair();
|
||||
|
||||
return new Promise((res, rej) => {
|
||||
jwt.sign(
|
||||
{ id, iat, kid: keyPair.fingerprint },
|
||||
keyPair.privateKey,
|
||||
{
|
||||
algorithm: "ES512",
|
||||
},
|
||||
(err, token) => {
|
||||
if (err) return rej(err);
|
||||
return res(token);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Get ECDSA keypair from file or generate it
|
||||
export async function loadOrGenerateKeypair() {
|
||||
let privateKey: string | crypto.KeyObject;
|
||||
let publicKey: string | crypto.KeyObject;
|
||||
|
||||
if (existsSync("jwt.key") && existsSync("jwt.key.pub")) {
|
||||
const [loadedPrivateKey, loadedPublicKey] = await Promise.all([
|
||||
fs.readFile("jwt.key", "utf-8"),
|
||||
fs.readFile("jwt.key.pub", "utf-8"),
|
||||
]);
|
||||
|
||||
privateKey = loadedPrivateKey;
|
||||
publicKey = loadedPublicKey;
|
||||
} else {
|
||||
console.log("[JWT] Generating new keypair");
|
||||
const res = crypto.generateKeyPairSync("ec", {
|
||||
namedCurve: "secp521r1",
|
||||
publicKeyEncoding: {
|
||||
type: "spki",
|
||||
format: "pem"
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: "pkcs8",
|
||||
format: "pem"
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
fs.writeFile("jwt.key", res.privateKey),
|
||||
fs.writeFile("jwt.key.pub", res.publicKey),
|
||||
]);
|
||||
|
||||
privateKey = res.privateKey;
|
||||
publicKey = res.publicKey;
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Invalid token format");
|
||||
}
|
||||
|
||||
const publicKeyForHash = typeof publicKey === 'string'
|
||||
? crypto.createPublicKey(publicKey)
|
||||
: publicKey;
|
||||
|
||||
const fingerprint = crypto
|
||||
.createHash("sha256")
|
||||
.update(publicKeyForHash.export({ format: "pem", type: "spki" }))
|
||||
.digest("hex");
|
||||
const [p1, p2, sig] = parts;
|
||||
const expected = b64url(sign(`${p1}.${p2}`));
|
||||
|
||||
return { privateKey, publicKey, fingerprint };
|
||||
}
|
||||
if (
|
||||
!crypto.timingSafeEqual(
|
||||
Buffer.from(sig),
|
||||
Buffer.from(expected),
|
||||
)
|
||||
) {
|
||||
throw new Error("Invalid token signature");
|
||||
}
|
||||
|
||||
const id = b64urlDecode(p1);
|
||||
const iat = Number(b64urlDecode(p2));
|
||||
|
||||
if (!Number.isFinite(iat)) {
|
||||
throw new Error("Invalid timestamp");
|
||||
}
|
||||
|
||||
const user = await User.findOne({
|
||||
where: { id },
|
||||
select: [
|
||||
...(opts?.select || []),
|
||||
"id",
|
||||
"bot",
|
||||
"disabled",
|
||||
"deleted",
|
||||
"rights",
|
||||
"data",
|
||||
],
|
||||
relations: opts?.relations,
|
||||
});
|
||||
|
||||
if (!user) throw new Error("User not found");
|
||||
if (user.disabled) throw new Error("User disabled");
|
||||
if (user.deleted) throw new Error("User deleted");
|
||||
|
||||
const unixTime = (iat + DISCORD_EPOCH) * 1000;
|
||||
if (unixTime < new Date(user.data.valid_tokens_since).setSeconds(0, 0)) {
|
||||
throw new Error("Token revoked");
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
decoded: { id, iat },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user