diff --git a/server/migrations/20240930113921-hash-api-keys.js b/server/migrations/20240930113921-hash-api-keys.js new file mode 100644 index 0000000000..7a6b7f0ad1 --- /dev/null +++ b/server/migrations/20240930113921-hash-api-keys.js @@ -0,0 +1,29 @@ +"use strict"; + +const { execFileSync } = require("child_process"); +const path = require("path"); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up() { + if ( + process.env.NODE_ENV === "test" || + process.env.DEPLOYMENT === "hosted" + ) { + return; + } + + const scriptName = path.basename(__filename); + const scriptPath = path.join( + process.cwd(), + "build", + `server/scripts/${scriptName}` + ); + + execFileSync("node", [scriptPath], { stdio: "inherit" }); + }, + + async down() { + // noop + }, +}; diff --git a/server/models/ApiKey.ts b/server/models/ApiKey.ts index 91ebd791c2..7a4cc4604a 100644 --- a/server/models/ApiKey.ts +++ b/server/models/ApiKey.ts @@ -2,7 +2,6 @@ import crypto from "crypto"; import { subMinutes } from "date-fns"; import randomstring from "randomstring"; import { InferAttributes, InferCreationAttributes, Op } from "sequelize"; -import { type BuildOptions } from "sequelize"; import { Column, Table, @@ -12,6 +11,7 @@ import { ForeignKey, IsDate, DataType, + AfterFind, BeforeSave, } from "sequelize-typescript"; import { ApiKeyValidation } from "@shared/validations"; @@ -28,18 +28,6 @@ class ApiKey extends ParanoidModel< > { static prefix = "ol_api_"; - constructor( - values?: Partial>, - options?: BuildOptions - ) { - // Temporary until last4 is backfilled and secret is removed. - if (values?.secret) { - values.last4 = values.secret.slice(-4); - } - - super(values, options); - } - @Length({ min: ApiKeyValidation.minNameLength, max: ApiKeyValidation.maxNameLength, @@ -76,6 +64,16 @@ class ApiKey extends ParanoidModel< // hooks + @AfterFind + public static async afterFindHook(models: ApiKey | ApiKey[]) { + const modelsArray = Array.isArray(models) ? models : [models]; + for (const model of modelsArray) { + if (model?.secret) { + model.last4 = model.secret.slice(-4); + } + } + } + @BeforeValidate public static async generateSecret(model: ApiKey) { if (!model.hash) { @@ -99,7 +97,7 @@ class ApiKey extends ParanoidModel< * @param key The input string to hash * @returns The hashed API key */ - private static hash(key: string) { + public static hash(key: string) { return crypto.createHash("sha256").update(key).digest("hex"); } diff --git a/server/scripts/20240930113921-hash-api-keys.ts b/server/scripts/20240930113921-hash-api-keys.ts new file mode 100644 index 0000000000..c361d422ce --- /dev/null +++ b/server/scripts/20240930113921-hash-api-keys.ts @@ -0,0 +1,53 @@ +import "./bootstrap"; +import { Transaction } from "sequelize"; +import { ApiKey } from "@server/models"; +import { sequelize } from "@server/storage/database"; + +let page = parseInt(process.argv[2], 10); +page = Number.isNaN(page) ? 0 : page; + +export default async function main(exit = false, limit = 100) { + const work = async (page: number): Promise => { + console.log(`Backfill apiKey hash… page ${page}`); + let apiKeys: ApiKey[] = []; + await sequelize.transaction(async (transaction) => { + apiKeys = await ApiKey.unscoped().findAll({ + limit, + offset: page * limit, + order: [["createdAt", "ASC"]], + lock: Transaction.LOCK.UPDATE, + transaction, + }); + + for (const apiKey of apiKeys) { + try { + if (!apiKey.hash) { + console.log(`Migrating ${apiKey.id}…`); + apiKey.value = apiKey.secret; + apiKey.hash = ApiKey.hash(apiKey.secret); + // @ts-expect-error secret is deprecated + apiKey.secret = null; + await apiKey.save({ transaction }); + } + } catch (err) { + console.error(`Failed at ${apiKey.id}:`, err); + continue; + } + } + }); + return apiKeys.length === limit ? work(page + 1) : undefined; + }; + + await work(page); + + console.log("Backfill complete"); + + if (exit) { + process.exit(0); + } +} + +// In the test suite we import the script rather than run via node CLI +if (process.env.NODE_ENV !== "test") { + void main(true); +}