feat: admin controls
This commit is contained in:
@@ -1,116 +1,82 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import * as z from 'zod';
|
||||
import { providers } from '~~/drizzle/schema';
|
||||
import { auth } from '~~/lib/auth';
|
||||
import { db } from '~~/server/lib/db';
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import * as z from "zod";
|
||||
import { providers, recoveryLinks } from "~~/drizzle/schema";
|
||||
import { lockAccount, revokeTwoFactorTrust } from "~~/server/lib/account-security";
|
||||
import { auth } from "~~/lib/auth";
|
||||
import { authTransaction } from "~~/server/lib/auth-transaction";
|
||||
|
||||
/**
|
||||
* Changes the password and swaps in the provider API keys re-encrypted with
|
||||
* the new password in one go. API keys are encrypted in the browser with a key
|
||||
* derived from the password, so a password change that leaves the old
|
||||
* ciphertexts behind would make every saved key unreadable.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const userId = event.context.user!.id as string;
|
||||
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8).max(128),
|
||||
providerKeys: z.array(z.object({
|
||||
id: z.string(),
|
||||
apiKey: z.string().min(1),
|
||||
})),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
const { user } = await protectRoute(event);
|
||||
const result = await readValidatedBody(event, (body) => z.object({
|
||||
currentPassword: z.string().min(1).max(128),
|
||||
newPassword: z.string().min(8).max(128),
|
||||
providerKeys: z.array(z.object({
|
||||
id: z.string(),
|
||||
originalApiKey: z.string().min(1),
|
||||
apiKey: z.string().min(1),
|
||||
})),
|
||||
}).safeParse(body));
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: result.error.issues[0]!.message,
|
||||
});
|
||||
throw createError({ statusCode: 400, message: result.error.issues[0]?.message });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword, providerKeys } = result.data;
|
||||
|
||||
const existing = await db.query.providers.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
const newKeys = new Map(providerKeys.map(p => [p.id, p.apiKey]));
|
||||
|
||||
// Every saved key has to come back re-encrypted, otherwise it would be
|
||||
// stuck encrypted with the old password.
|
||||
const withKey = existing.filter(p => !!p.config.apiKey);
|
||||
const complete = withKey.every(p => newKeys.has(p.id)) && newKeys.size === withKey.length;
|
||||
|
||||
if (!complete) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'Provider keys out of date',
|
||||
data: {
|
||||
code: 'PROVIDER_KEYS_OUT_OF_DATE',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let cookies: string[] = [];
|
||||
const replacements = new Map(providerKeys.map(key => [key.id, key]));
|
||||
let cookies: string[];
|
||||
|
||||
try {
|
||||
// The password change runs inside the transaction so a rejected
|
||||
// password rolls the key updates back.
|
||||
await db.transaction(async (tx) => {
|
||||
for (const provider of withKey) {
|
||||
await tx.update(providers)
|
||||
.set({
|
||||
config: {
|
||||
...provider.config,
|
||||
apiKey: newKeys.get(provider.id)!,
|
||||
},
|
||||
})
|
||||
.where(and(eq(providers.id, provider.id), eq(providers.userId, userId)));
|
||||
cookies = await authTransaction(async (tx) => {
|
||||
await lockAccount(tx, user.id);
|
||||
const existing = await tx.select().from(providers).where(eq(providers.userId, user.id));
|
||||
const withKey = existing.filter(provider => !!provider.config.apiKey);
|
||||
const complete = replacements.size === providerKeys.length
|
||||
&& replacements.size === withKey.length
|
||||
&& withKey.every(provider => replacements.get(provider.id)?.originalApiKey === provider.config.apiKey);
|
||||
|
||||
if (!complete) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: "Provider keys out of date",
|
||||
data: { code: "PROVIDER_KEYS_OUT_OF_DATE" },
|
||||
});
|
||||
}
|
||||
|
||||
const { headers } = await auth.api.changePassword({
|
||||
body: {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
// other devices still hold an encryption key derived from the
|
||||
// old password, so they have to sign in again
|
||||
revokeOtherSessions: true,
|
||||
},
|
||||
body: { currentPassword, newPassword, revokeOtherSessions: true },
|
||||
headers: event.headers,
|
||||
returnHeaders: true,
|
||||
});
|
||||
|
||||
cookies = headers.getSetCookie();
|
||||
});
|
||||
} catch (error: any) {
|
||||
const code = error?.body?.code as string | undefined;
|
||||
await tx.delete(recoveryLinks).where(eq(recoveryLinks.userId, user.id));
|
||||
await revokeTwoFactorTrust(tx, user.id);
|
||||
|
||||
if (code === undefined) {
|
||||
console.error('Failed to change password:', error);
|
||||
// Invalidate pending config writes even for providers without a key.
|
||||
const updatedAt = new Date(Math.max(Date.now(), ...existing.map(provider => provider.updatedAt.getTime() + 1)));
|
||||
await tx.update(providers).set({ updatedAt }).where(eq(providers.userId, user.id));
|
||||
|
||||
for (const key of providerKeys) {
|
||||
await tx.update(providers).set({
|
||||
config: sql`jsonb_set(${providers.config}, '{apiKey}', ${JSON.stringify(key.apiKey)}::jsonb)`,
|
||||
updatedAt,
|
||||
}).where(and(eq(providers.id, key.id), eq(providers.userId, user.id)));
|
||||
}
|
||||
|
||||
return headers.getSetCookie();
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "body" in error) {
|
||||
const body = z.object({ code: z.string(), message: z.string().optional() }).safeParse(error.body);
|
||||
if (body.success) {
|
||||
throw createError({ statusCode: 400, message: body.data.message, data: { code: body.data.code } });
|
||||
}
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: code === undefined ? 500 : 400,
|
||||
statusMessage: 'Failed to change password',
|
||||
message: error?.body?.message ?? 'Failed to change password',
|
||||
data: {
|
||||
code: code ?? 'INTERNAL_ERROR',
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
// No replacement session reaches the browser until its transaction commits.
|
||||
for (const cookie of cookies) {
|
||||
appendResponseHeader(event, 'set-cookie', cookie);
|
||||
appendResponseHeader(event, "set-cookie", cookie);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user