feat: admin controls
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { and, eq, ne } from 'drizzle-orm';
|
||||
import * as z from 'zod';
|
||||
import { users } from '~~/drizzle/schema';
|
||||
import { auth } from '~~/lib/auth';
|
||||
import { hasUsableAdministrator } from '~~/server/lib/account-security';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
/**
|
||||
@@ -43,23 +42,16 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const twoFactorEnabled = !!account?.twoFactorEnabled;
|
||||
|
||||
// an instance always keeps one admin, someone has to be able to manage it
|
||||
if (account?.role === 'admin') {
|
||||
const otherAdmins = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.role, 'admin'), ne(users.id, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (otherAdmins.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'The last admin cannot be deleted',
|
||||
data: {
|
||||
code: 'LAST_ADMIN',
|
||||
},
|
||||
});
|
||||
}
|
||||
// Checked again when the account is deleted. Doing it here as well keeps
|
||||
// a deletion that is refused anyway from using up a backup code.
|
||||
if (account?.role === 'admin' && !await db.transaction(tx => hasUsableAdministrator(tx, userId))) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'The last usable admin cannot be deleted',
|
||||
data: {
|
||||
code: 'LAST_ADMIN',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (twoFactorEnabled && code === undefined) {
|
||||
@@ -111,7 +103,14 @@ export default defineEventHandler(async (event) => {
|
||||
});
|
||||
}
|
||||
|
||||
await deleteAccount(userId);
|
||||
const deleted = await deleteAccount(userId);
|
||||
if (!deleted.ok) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: "The last usable admin cannot be deleted",
|
||||
data: { code: deleted.error },
|
||||
});
|
||||
}
|
||||
|
||||
// the session is gone, but the browser would keep trusting its cached
|
||||
// copy for a few minutes unless the cookies go too
|
||||
@@ -121,7 +120,6 @@ export default defineEventHandler(async (event) => {
|
||||
deleteCookie(event, cookie.name, {
|
||||
path: cookie.attributes.path,
|
||||
secure: cookie.attributes.secure,
|
||||
sameSite: cookie.attributes.sameSite,
|
||||
httpOnly: cookie.attributes.httpOnly,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
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 sessionData = await protectRoute(event);
|
||||
|
||||
const id = getRouterParam(event, 'id')!;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { auth } from '~~/lib/auth';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
/**
|
||||
@@ -6,14 +5,7 @@ import { db } from '~~/server/lib/db';
|
||||
* 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 sessionData = await protectRoute(event);
|
||||
|
||||
const result = await db.query.sessions.findMany({
|
||||
where: {
|
||||
|
||||
@@ -34,7 +34,14 @@ export default defineEventHandler(async (event) => {
|
||||
});
|
||||
}
|
||||
|
||||
await deleteAccount(id);
|
||||
const deleted = await deleteAccount(id);
|
||||
if (!deleted.ok) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: "The last usable admin cannot be deleted",
|
||||
data: { code: deleted.error },
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
// also turn two-factor off and sign every device out
|
||||
// also turn two-factor off, every device is signed out either way
|
||||
resetSecurity: z.boolean(),
|
||||
})
|
||||
.safeParse(body),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { twoFactors, users } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
import { lockAccount, revokeTwoFactorTrust } from "~~/server/lib/account-security";
|
||||
|
||||
/**
|
||||
* Turns two-factor sign-in off for a user who lost their authenticator app
|
||||
@@ -26,6 +27,8 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await lockAccount(tx, id);
|
||||
await revokeTwoFactorTrust(tx, id);
|
||||
await tx.delete(twoFactors).where(eq(twoFactors.userId, id));
|
||||
await tx.update(users).set({ twoFactorEnabled: false }).where(eq(users.id, id));
|
||||
});
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
import { auth } from '~~/lib/auth';
|
||||
import { auth } from "~~/lib/auth";
|
||||
import { lockAdministration } from "~~/server/lib/account-security";
|
||||
import { authTransaction } from "~~/server/lib/auth-transaction";
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
return auth.handler(toWebRequest(event));
|
||||
const SIGN_UP = "/api/auth/sign-up/email";
|
||||
|
||||
// Requests that have to be undone as a whole when they fail. The hooks in
|
||||
// lib/auth.ts refuse to run these without a transaction, so a URL that slips
|
||||
// past this list is rejected there instead of going through unguarded.
|
||||
const ADMIN_MUTATIONS = new Set([
|
||||
"/api/auth/admin/set-role",
|
||||
"/api/auth/admin/ban-user",
|
||||
"/api/auth/admin/unban-user",
|
||||
"/api/auth/admin/update-user",
|
||||
]);
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const request = toWebRequest(event);
|
||||
const path = new URL(request.url).pathname.replace(/\/+$/, "");
|
||||
const adminMutation = ADMIN_MUTATIONS.has(path);
|
||||
|
||||
if (request.method !== "POST" || (!adminMutation && path !== SIGN_UP)) {
|
||||
return auth.handler(request);
|
||||
}
|
||||
|
||||
try {
|
||||
return await authTransaction(async (tx) => {
|
||||
if (adminMutation) {
|
||||
// Deletion, banning and demotion all wait for each other here.
|
||||
await lockAdministration(tx);
|
||||
}
|
||||
|
||||
const response = await auth.handler(request);
|
||||
if (!response.ok) {
|
||||
// The auth handler returns errors as responses; roll back any writes.
|
||||
throw response;
|
||||
}
|
||||
return response;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
return error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,8 +22,11 @@ export default defineEventHandler(async (event) => {
|
||||
// Path 2: Session auth (for client-side access)
|
||||
if (!authorized) {
|
||||
try {
|
||||
const sessionData = await auth.api.getSession({ headers: event.headers });
|
||||
if (sessionData) {
|
||||
const sessionData = await auth.api.getSession({
|
||||
headers: event.headers,
|
||||
query: { disableCookieCache: true },
|
||||
});
|
||||
if (sessionData && !sessionData.user.banned) {
|
||||
event.context.user = sessionData.user;
|
||||
authorized = true;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
import { generateFileToken } from '~~/server/utils/file-token';
|
||||
|
||||
const FILE_PREFIX = '/api/files/';
|
||||
|
||||
// Uploaded avatars are served to signed in people only, and whoever opens an
|
||||
// invite is not signed in yet. A short-lived signed URL lets them see it.
|
||||
const publicImage = (image: string | null): string | null => {
|
||||
const secret = process.env.BETTER_AUTH_SECRET;
|
||||
|
||||
if (!image || !image.startsWith(FILE_PREFIX) || !secret) return image;
|
||||
|
||||
const { exp, sig } = generateFileToken(image.slice(FILE_PREFIX.length), secret);
|
||||
|
||||
return `${image}?exp=${exp}&sig=${sig}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tells the register page whether an invite link still works and who it is
|
||||
* from. Public on purpose, the person opening the link has no account yet.
|
||||
@@ -16,7 +32,7 @@ export default defineEventHandler(async (event) => {
|
||||
inviter: {
|
||||
id: invite.inviterId,
|
||||
name: invite.inviterName,
|
||||
image: invite.inviterImage,
|
||||
image: publicImage(invite.inviterImage),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,68 +1,63 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { providers } from "~~/drizzle/schema";
|
||||
import { and, eq, gt } from "drizzle-orm";
|
||||
import * as z from "zod";
|
||||
import { providers, sessions } from "~~/drizzle/schema";
|
||||
import { lockAccount } from "~~/server/lib/account-security";
|
||||
import { db } from "~~/server/lib/db";
|
||||
import * as z from 'zod';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const userId = event.context.user!.id;
|
||||
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
config: z.object({
|
||||
apiKey: z.string().optional(),
|
||||
apiProxyUrl: z.string().optional(),
|
||||
}).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
const { user, session } = await protectRoute(event);
|
||||
const result = await readValidatedBody(event, (body) => z.object({
|
||||
config: z.object({
|
||||
apiKey: z.string().optional(),
|
||||
apiProxyUrl: z.string().optional(),
|
||||
}).optional(),
|
||||
expectedUpdatedAt: z.iso.datetime().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
}).refine(body => body.config === undefined || body.expectedUpdatedAt !== undefined, {
|
||||
message: "Provider config updates require a revision",
|
||||
}).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 providerId = getRouterParam(event, 'providerId')!;
|
||||
|
||||
const { config, enabled } = result.data;
|
||||
|
||||
const existing = await db.query.providers.findFirst({
|
||||
where: {
|
||||
id: providerId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid provider',
|
||||
});
|
||||
const providerId = getRouterParam(event, "providerId");
|
||||
if (!providerId) {
|
||||
throw createError({ statusCode: 400, statusMessage: "Invalid provider" });
|
||||
}
|
||||
const { config, enabled, expectedUpdatedAt } = result.data;
|
||||
|
||||
const res = await db.update(providers)
|
||||
.set({
|
||||
config,
|
||||
return await db.transaction(async (tx) => {
|
||||
await lockAccount(tx, user.id);
|
||||
// A request may have waited behind a password rotation or recovery.
|
||||
const [activeSession] = await tx.select({ id: sessions.id }).from(sessions).where(and(
|
||||
eq(sessions.id, session.id), eq(sessions.userId, user.id), gt(sessions.expiresAt, new Date()),
|
||||
));
|
||||
if (!activeSession) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
||||
}
|
||||
|
||||
const [existing] = await tx.select().from(providers).where(and(
|
||||
eq(providers.id, providerId), eq(providers.userId, user.id),
|
||||
));
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, statusMessage: "Invalid provider" });
|
||||
}
|
||||
if (config !== undefined && existing.updatedAt.getTime() !== new Date(expectedUpdatedAt ?? "").getTime()) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: "Provider config out of date",
|
||||
data: { code: "PROVIDER_KEYS_OUT_OF_DATE" },
|
||||
});
|
||||
}
|
||||
|
||||
const updatedAt = new Date(Math.max(Date.now(), existing.updatedAt.getTime() + 1));
|
||||
await tx.update(providers).set({
|
||||
config: config === undefined ? undefined : { ...existing.config, ...config },
|
||||
enabled,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(providers.id, providerId),
|
||||
eq(providers.userId, userId),
|
||||
)
|
||||
);
|
||||
updatedAt,
|
||||
}).where(and(eq(providers.id, providerId), eq(providers.userId, user.id)));
|
||||
|
||||
if (res.rowCount === 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid provider',
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
return { ok: true, updatedAt };
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { and, eq, gt, sql } from 'drizzle-orm';
|
||||
import * as z from 'zod';
|
||||
import { providers, recoveryLinks, sessions, twoFactors, users } from '~~/drizzle/schema';
|
||||
import { accounts, providers, recoveryLinks, sessions, twoFactors, users } from '~~/drizzle/schema';
|
||||
import { lockAccount, revokeTwoFactorTrust } from "~~/server/lib/account-security";
|
||||
import { authTransaction } from "~~/server/lib/auth-transaction";
|
||||
import { auth } from '~~/lib/auth';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
/**
|
||||
* Sets a password with a one-time link.
|
||||
@@ -33,24 +34,45 @@ export default defineEventHandler(async (event) => {
|
||||
});
|
||||
}
|
||||
|
||||
const tokenHash = hashRecoveryToken(getRouterParam(event, 'token')!);
|
||||
const token = getRouterParam(event, 'token')!;
|
||||
const tokenHash = hashRecoveryToken(token);
|
||||
const { password } = result.data;
|
||||
|
||||
const invalidLink = () => createError({
|
||||
statusCode: 410,
|
||||
statusMessage: 'This recovery link is not valid anymore',
|
||||
data: {
|
||||
code: 'RECOVERY_LINK_INVALID',
|
||||
},
|
||||
});
|
||||
|
||||
// Hashing a password is slow on purpose and anyone can call this, so a
|
||||
// link that does not work is turned away before any of that is done.
|
||||
const candidate = await findRecoveryLink(token);
|
||||
if (!candidate) {
|
||||
throw invalidLink();
|
||||
}
|
||||
|
||||
const { password: hasher, internalAdapter } = await auth.$context;
|
||||
const passwordHash = await hasher.hash(password);
|
||||
|
||||
const recovered = await db.transaction(async (tx) => {
|
||||
const recovered = await authTransaction(async (tx) => {
|
||||
await lockAccount(tx, candidate.userId);
|
||||
// deleting the link is what makes it work once: of two requests with
|
||||
// the same link only one gets a row back
|
||||
const [link] = await tx.delete(recoveryLinks)
|
||||
.where(and(eq(recoveryLinks.tokenHash, tokenHash), gt(recoveryLinks.expiresAt, new Date())))
|
||||
.returning({ userId: recoveryLinks.userId, resetSecurity: recoveryLinks.resetSecurity });
|
||||
|
||||
if (!link) return null;
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// if either of these fails the link comes back with the rest of the
|
||||
// transaction
|
||||
if (!await hasPassword(link.userId)) {
|
||||
const [credential] = await tx.select({ id: accounts.id }).from(accounts)
|
||||
.where(and(eq(accounts.userId, link.userId), eq(accounts.providerId, 'credential')));
|
||||
if (!credential) {
|
||||
await internalAdapter.linkAccount({
|
||||
providerId: 'credential',
|
||||
accountId: link.userId,
|
||||
@@ -63,32 +85,37 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
await internalAdapter.updatePassword(link.userId, passwordHash);
|
||||
|
||||
await tx.update(providers).set({
|
||||
updatedAt: sql`greatest(date_trunc('milliseconds', clock_timestamp()), date_trunc('milliseconds', ${providers.updatedAt}) + interval '1 millisecond')`,
|
||||
}).where(eq(providers.userId, link.userId));
|
||||
|
||||
// A provider without its key can't be used, so it is switched off
|
||||
// until the key is entered again. The provider and its models stay.
|
||||
await tx.update(providers)
|
||||
.set({
|
||||
config: sql`${providers.config} - 'apiKey'`,
|
||||
enabled: false,
|
||||
updatedAt: sql`${providers.updatedAt}`,
|
||||
})
|
||||
.where(and(eq(providers.userId, link.userId), sql`coalesce(${providers.config}->>'apiKey', '') <> ''`));
|
||||
|
||||
// Every device is signed out, always: the ones still signed in hold
|
||||
// an encryption key derived from the old password.
|
||||
await tx.delete(sessions).where(eq(sessions.userId, link.userId));
|
||||
|
||||
await revokeTwoFactorTrust(tx, link.userId);
|
||||
|
||||
if (link.resetSecurity) {
|
||||
await tx.delete(twoFactors).where(eq(twoFactors.userId, link.userId));
|
||||
await tx.update(users).set({ twoFactorEnabled: false }).where(eq(users.id, link.userId));
|
||||
await tx.delete(sessions).where(eq(sessions.userId, link.userId));
|
||||
}
|
||||
|
||||
return 'recovered';
|
||||
});
|
||||
|
||||
// used or expired between the check above and here
|
||||
if (recovered === null) {
|
||||
throw createError({
|
||||
statusCode: 410,
|
||||
statusMessage: 'This recovery link is not valid anymore',
|
||||
data: {
|
||||
code: 'RECOVERY_LINK_INVALID',
|
||||
},
|
||||
});
|
||||
throw invalidLink();
|
||||
}
|
||||
|
||||
return { newAccount: recovered === 'created' };
|
||||
|
||||
@@ -2,7 +2,7 @@ import { sql } from 'drizzle-orm';
|
||||
import * as z from 'zod';
|
||||
import { INSTANCE_SETTINGS_ID, instanceSettings, users } from '~~/drizzle/schema';
|
||||
import { auth } from '~~/lib/auth';
|
||||
import { db } from '~~/server/lib/db';
|
||||
import { authTransaction } from '~~/server/lib/auth-transaction';
|
||||
|
||||
// any fixed number works, it only has to be the same for every request
|
||||
const SETUP_LOCK_ID = 7_261_008;
|
||||
@@ -40,7 +40,7 @@ export default defineEventHandler(async (event) => {
|
||||
let alreadyDone = false;
|
||||
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await authTransaction(async (tx) => {
|
||||
// two people submitting the form at the same moment wait here, and
|
||||
// the second one then finds the first one's account
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(${SETUP_LOCK_ID})`);
|
||||
|
||||
Reference in New Issue
Block a user