feat: improve account security
Support TOTP 2fa, allow for changing password, add session management and account removal.
This commit is contained in:
@@ -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 };
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
});
|
||||
Reference in New Issue
Block a user