feat: improve account security

Support TOTP 2fa, allow for changing password, add session management
and account removal.
This commit is contained in:
Zoe
2026-10-08 12:43:22 -05:00
parent ff33e9c573
commit 4bba529ba8
27 changed files with 4874 additions and 468 deletions
+139
View File
@@ -0,0 +1,139 @@
import { eq } from 'drizzle-orm';
import * as z from 'zod';
import {
agents,
files,
models,
providers,
settings,
toolCalls,
topics,
users,
} from '~~/drizzle/schema';
import { auth } from '~~/lib/auth';
import { db } from '~~/server/lib/db';
/**
* Deletes the signed in user's account and everything that belongs to it.
*
* The app tables only store a plain user id, nothing cascades from the users
* table, so every one of them is cleared here. It all happens in one
* transaction: either the whole account is gone or nothing changed.
*
* Objects in file storage are left alone on purpose.
*/
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id as string;
const result = await readValidatedBody(event, (body) =>
z
.object({
password: z.string().min(1),
// required when two-factor is on: a code from the authenticator
// app, or one of the backup codes
code: z.string().min(1).optional(),
backupCode: z.boolean().optional(),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
const { password, code, backupCode } = result.data;
// read from the database, the session can lag behind by a few minutes
const account = await db.query.users.findFirst({
where: {
id: userId,
},
});
const twoFactorEnabled = !!account?.twoFactorEnabled;
if (twoFactorEnabled && code === undefined) {
throw createError({
statusCode: 400,
statusMessage: 'Two-factor code required',
data: {
code: 'TWO_FACTOR_REQUIRED',
},
});
}
try {
await auth.api.verifyPassword({
body: {
password,
},
headers: event.headers,
});
if (twoFactorEnabled && backupCode) {
await auth.api.verifyBackupCode({
body: {
code: code!,
},
headers: event.headers,
});
} else if (twoFactorEnabled) {
await auth.api.verifyTOTP({
body: {
code: code!,
},
headers: event.headers,
});
}
} catch (error: any) {
const errorCode = error?.body?.code as string | undefined;
if (errorCode === undefined) {
console.error('Failed to verify credentials:', error);
}
throw createError({
statusCode: errorCode === undefined ? 500 : 400,
statusMessage: 'Failed to delete account',
data: {
code: errorCode ?? 'INTERNAL_ERROR',
},
});
}
await db.transaction(async (tx) => {
// messages, their parts, generations and attachments cascade from topics
await tx.delete(topics).where(eq(topics.userId, userId));
await tx.delete(agents).where(eq(agents.userId, userId));
await tx.delete(models).where(eq(models.userId, userId));
await tx.delete(providers).where(eq(providers.userId, userId));
// nothing points a tool call back at its message, so these never cascade
await tx.delete(toolCalls).where(eq(toolCalls.userId, userId));
// embeddings go with their file
await tx.delete(files).where(eq(files.userId, userId));
await tx.delete(settings).where(eq(settings.userId, userId));
// sessions, accounts and two-factor rows cascade from the user
await tx.delete(users).where(eq(users.id, userId));
});
// the session is gone, but the browser would keep trusting its cached
// copy for a few minutes unless the cookies go too
const { authCookies } = await auth.$context;
for (const cookie of Object.values(authCookies)) {
deleteCookie(event, cookie.name, {
path: cookie.attributes.path,
secure: cookie.attributes.secure,
sameSite: cookie.attributes.sameSite,
httpOnly: cookie.attributes.httpOnly,
});
}
return { ok: true };
});
+29
View File
@@ -0,0 +1,29 @@
import { db } from '~~/server/lib/db';
/**
* Reports when the password was last changed, or null if it is still the one
* the account was created with. The credential account row is only written
* when the account is created and when its password changes, so its
* updatedAt is the time of the last change.
*/
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id as string;
const account = await db.query.accounts.findFirst({
where: {
userId,
providerId: 'credential',
},
});
if (!account) {
return { changedAt: null };
}
// the two timestamps are written a moment apart on sign up
const changed = account.updatedAt.getTime() - account.createdAt.getTime() > 1000;
return { changedAt: changed ? account.updatedAt : null };
});
+116
View File
@@ -0,0 +1,116 @@
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 };
});
@@ -0,0 +1,40 @@
import { and, eq } from 'drizzle-orm';
import { sessions } from '~~/drizzle/schema';
import { auth } from '~~/lib/auth';
import { db } from '~~/server/lib/db';
export default defineEventHandler(async (event) => {
const sessionData = await auth.api.getSession({ headers: event.headers });
if (sessionData === null) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
});
}
const id = getRouterParam(event, 'id')!;
// signing out the current device goes through the normal sign out, which
// also clears its cookies
if (id === sessionData.session.id) {
throw createError({
statusCode: 400,
statusMessage: 'Cannot revoke the current session',
});
}
const deleted = await db.delete(sessions)
.where(and(eq(sessions.id, id), eq(sessions.userId, sessionData.user.id)))
.returning({ id: sessions.id });
if (deleted.length === 0) {
throw createError({
statusCode: 404,
statusMessage: 'Not Found',
message: 'Session not found',
});
}
return { ok: true };
});
+38
View File
@@ -0,0 +1,38 @@
import { auth } from '~~/lib/auth';
import { db } from '~~/server/lib/db';
/**
* Lists the signed in devices for the current user. Session tokens are left
* out on purpose: the client only ever needs the id to sign a device out.
*/
export default defineEventHandler(async (event) => {
const sessionData = await auth.api.getSession({ headers: event.headers });
if (sessionData === null) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
});
}
const result = await db.query.sessions.findMany({
where: {
userId: sessionData.user.id,
expiresAt: {
gt: new Date(),
},
},
orderBy: {
updatedAt: 'desc',
},
});
return result.map(session => ({
id: session.id,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
ipAddress: session.ipAddress,
userAgent: session.userAgent,
current: session.id === sessionData.session.id,
}));
});
+1 -1
View File
@@ -22,7 +22,7 @@ export default defineEventHandler(async (event) => {
// Path 2: Session auth (for client-side access)
if (!authorized) {
try {
const sessionData = await auth.api.getSession(event);
const sessionData = await auth.api.getSession({ headers: event.headers });
if (sessionData) {
event.context.user = sessionData.user;
authorized = true;