mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
feat(auth): implement migration script for auth secret and refactor secret handling
- Added a new script `migrate-auth-secret.ts` to facilitate the migration of 2FA secrets when changing the BETTER_AUTH_SECRET. - Updated `package.json` to include a command for running the migration script. - Refactored the handling of BETTER_AUTH_SECRET to improve security by removing the hardcoded default and introducing a fallback mechanism using environment variables or Docker secrets. - Updated the authentication logic to utilize the new `betterAuthSecret` function for retrieving the secret.
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
"wait-for-postgres-dev": "tsx -r dotenv/config wait-for-postgres.ts",
|
||||
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
|
||||
"reset-2fa": "node -r dotenv/config dist/reset-2fa.mjs",
|
||||
"migrate-auth-secret": "tsx -r dotenv/config scripts/migrate-auth-secret.ts",
|
||||
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
|
||||
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
|
||||
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
|
||||
|
||||
97
apps/dokploy/scripts/migrate-auth-secret.ts
Normal file
97
apps/dokploy/scripts/migrate-auth-secret.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Use this command to automatically migrate the auth secret: curl -sSL https://dokploy.com/security/0.29.3.sh | bash
|
||||
* Migration script: re-encrypt 2FA secrets after rotating BETTER_AUTH_SECRET.
|
||||
*
|
||||
* Usage:
|
||||
* OLD_SECRET=<old_secret> NEW_SECRET=<new_secret> npx tsx apps/dokploy/scripts/migrate-auth-secret.ts
|
||||
*
|
||||
* Both OLD_SECRET and NEW_SECRET are required.
|
||||
* Run this BEFORE restarting Dokploy with the new secret.
|
||||
*/
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { twoFactor } from "@dokploy/server/db/schema";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const OLD_SECRET = process.env.OLD_SECRET as string;
|
||||
const NEW_SECRET = process.env.NEW_SECRET as string;
|
||||
|
||||
if (!OLD_SECRET || !NEW_SECRET) {
|
||||
console.error(
|
||||
"❌ OLD_SECRET and NEW_SECRET environment variables are required.",
|
||||
);
|
||||
console.error(
|
||||
" Usage: OLD_SECRET=<old> NEW_SECRET=<new> npx tsx apps/dokploy/scripts/migrate-auth-secret.ts",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (OLD_SECRET === NEW_SECRET) {
|
||||
console.error("❌ OLD_SECRET and NEW_SECRET must be different.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function reEncrypt(
|
||||
value: string,
|
||||
oldSecret: string,
|
||||
newSecret: string,
|
||||
): Promise<string> {
|
||||
const plaintext = await symmetricDecrypt({ key: oldSecret, data: value });
|
||||
return symmetricEncrypt({ key: newSecret, data: plaintext });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("🔍 Fetching 2FA records...");
|
||||
const records = await db.select().from(twoFactor);
|
||||
|
||||
if (records.length === 0) {
|
||||
console.log("✅ No 2FA records found, nothing to migrate.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📦 Found ${records.length} 2FA record(s) to migrate.`);
|
||||
|
||||
let migrated = 0;
|
||||
let failed = 0;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const record of records) {
|
||||
try {
|
||||
const [newSecret, newBackupCodes] = await Promise.all([
|
||||
reEncrypt(record.secret, OLD_SECRET, NEW_SECRET),
|
||||
reEncrypt(record.backupCodes, OLD_SECRET, NEW_SECRET),
|
||||
]);
|
||||
|
||||
await tx
|
||||
.update(twoFactor)
|
||||
.set({ secret: newSecret, backupCodes: newBackupCodes })
|
||||
.where(eq(twoFactor.id, record.id));
|
||||
|
||||
migrated++;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`❌ Failed to migrate record ${record.id} (userId: ${record.userId}):`,
|
||||
err,
|
||||
);
|
||||
failed++;
|
||||
throw err; // rollback the whole transaction
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`✅ Migrated ${migrated} record(s) successfully.`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(
|
||||
`❌ ${failed} record(s) failed — transaction was rolled back.`,
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Migration failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -83,11 +83,6 @@ const getDockerConfig = (): Docker => {
|
||||
|
||||
export const docker = getDockerConfig();
|
||||
|
||||
// When not set, use the legacy default so 2FA remains working for users who
|
||||
// enabled it before BETTER_AUTH_SECRET was introduced.
|
||||
export const BETTER_AUTH_SECRET =
|
||||
process.env.BETTER_AUTH_SECRET || "better-auth-secret-123456789";
|
||||
|
||||
export const paths = (isServer = false) => {
|
||||
const BASE_PATH =
|
||||
isServer || process.env.NODE_ENV === "production"
|
||||
|
||||
@@ -9,7 +9,7 @@ export const {
|
||||
POSTGRES_PORT = "5432",
|
||||
} = process.env;
|
||||
|
||||
function readSecret(path: string): string {
|
||||
export function readSecret(path: string): string {
|
||||
try {
|
||||
return fs.readFileSync(path, "utf8").trim();
|
||||
} catch {
|
||||
|
||||
28
packages/server/src/lib/auth-secret.ts
Normal file
28
packages/server/src/lib/auth-secret.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { readSecret } from "../db/constants";
|
||||
|
||||
const HARDCODED_LEGACY_SECRET = "better-auth-secret-123456789";
|
||||
|
||||
const { BETTER_AUTH_SECRET, BETTER_AUTH_SECRET_FILE } = process.env;
|
||||
|
||||
function resolveBetterAuthSecret(): string {
|
||||
if (BETTER_AUTH_SECRET) {
|
||||
return BETTER_AUTH_SECRET;
|
||||
}
|
||||
if (BETTER_AUTH_SECRET_FILE) {
|
||||
return readSecret(BETTER_AUTH_SECRET_FILE);
|
||||
}
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
console.warn(`
|
||||
⚠️ [DEPRECATED AUTH CONFIG]
|
||||
BETTER_AUTH_SECRET is not set via environment variable or Docker secret.
|
||||
Falling back to the insecure hardcoded default — this is a CRITICAL SECURITY RISK.
|
||||
This mode WILL BE REMOVED in a future release.
|
||||
|
||||
Please migrate to Docker Secrets:
|
||||
curl -sSL https://dokploy.com/security/0.29.3.sh | bash
|
||||
`);
|
||||
}
|
||||
return HARDCODED_LEGACY_SECRET;
|
||||
}
|
||||
|
||||
export const betterAuthSecret = resolveBetterAuthSecret();
|
||||
@@ -7,7 +7,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { APIError } from "better-auth/api";
|
||||
import { admin, organization, twoFactor } from "better-auth/plugins";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { BETTER_AUTH_SECRET, IS_CLOUD } from "../constants";
|
||||
import { IS_CLOUD } from "../constants";
|
||||
import { db } from "../db";
|
||||
import * as schema from "../db/schema";
|
||||
import {
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "../verification/send-verification-email";
|
||||
import { getPublicIpWithFallback } from "../wss/utils";
|
||||
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
|
||||
import { betterAuthSecret } from "./auth-secret";
|
||||
|
||||
const { handler, api } = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
@@ -38,8 +39,9 @@ const { handler, api } = betterAuth({
|
||||
"/organization/create",
|
||||
"/organization/update",
|
||||
"/organization/delete",
|
||||
...(!IS_CLOUD ? ["/verify-email"] : []),
|
||||
],
|
||||
secret: BETTER_AUTH_SECRET,
|
||||
secret: betterAuthSecret,
|
||||
...(!IS_CLOUD
|
||||
? {
|
||||
advanced: {
|
||||
|
||||
Reference in New Issue
Block a user