4bba529ba8
Support TOTP 2fa, allow for changing password, add session management and account removal.
117 lines
3.7 KiB
TypeScript
117 lines
3.7 KiB
TypeScript
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';
|
|
|
|
/**
|
|
* 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),
|
|
);
|
|
|
|
if (!result.success) {
|
|
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[] = [];
|
|
|
|
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)));
|
|
}
|
|
|
|
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,
|
|
},
|
|
headers: event.headers,
|
|
returnHeaders: true,
|
|
});
|
|
|
|
cookies = headers.getSetCookie();
|
|
});
|
|
} catch (error: any) {
|
|
const code = error?.body?.code as string | undefined;
|
|
|
|
if (code === undefined) {
|
|
console.error('Failed to change password:', error);
|
|
}
|
|
|
|
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',
|
|
},
|
|
});
|
|
}
|
|
|
|
for (const cookie of cookies) {
|
|
appendResponseHeader(event, 'set-cookie', cookie);
|
|
}
|
|
|
|
return { ok: true };
|
|
});
|