From ff7797d3f81d913e1e2fa675a8298141f63f9220 Mon Sep 17 00:00:00 2001
From: Zoe
Date: Thu, 15 Oct 2026 10:07:41 -0500
Subject: [PATCH] feat: admin controls
---
app/components/Dialog/ChangePassword.vue | 22 ++-
app/components/Dialog/RecoverAccount.vue | 10 +-
app/components/Dialog/TwoFactor.vue | 7 +-
app/components/Onboarding/ProviderForm.vue | 13 +-
app/composables/useModels.ts | 11 +-
app/pages/auth/login.vue | 2 +-
app/pages/auth/recover/[token].vue | 12 +-
app/pages/auth/recover/index.vue | 5 +-
app/pages/auth/register.vue | 2 +-
app/pages/auth/setup.vue | 7 +-
app/pages/settings/instance/users.vue | 18 +--
app/pages/settings/providers/[id].vue | 96 ++++++-----
app/pages/settings/providers/index.vue | 6 +-
app/pages/setup/instance.vue | 17 +-
app/pages/setup/provider.vue | 17 +-
app/pages/welcome.vue | 18 +--
drizzle/schema.ts | 3 +-
lib/auth.ts | 77 +++++----
nuxt.config.ts | 3 +-
scripts/recover.ts | 89 +++++------
server/api/account/index.delete.ts | 40 +++--
server/api/account/password.post.ts | 150 +++++++-----------
server/api/account/sessions/[id].delete.ts | 10 +-
server/api/account/sessions/index.get.ts | 10 +-
server/api/admin/users/[id]/index.delete.ts | 9 +-
server/api/admin/users/[id]/recovery.post.ts | 2 +-
.../api/admin/users/[id]/two-factor.delete.ts | 3 +
server/api/auth/[...all].ts | 47 +++++-
server/api/files/[...key].get.ts | 7 +-
server/api/invite/[token].get.ts | 18 ++-
.../api/provider/[providerId]/index.patch.ts | 109 ++++++-------
server/api/recover/[token].post.ts | 55 +++++--
server/api/setup/index.post.ts | 4 +-
server/lib/account-security.ts | 46 ++++++
server/lib/auth-transaction.ts | 30 ++++
server/utils/account.ts | 13 +-
server/utils/admin.ts | 18 +--
server/utils/auth.ts | 8 +-
server/utils/invites.ts | 16 +-
server/utils/recovery.ts | 5 +-
server/utils/setup.ts | 4 +-
41 files changed, 612 insertions(+), 427 deletions(-)
create mode 100644 server/lib/account-security.ts
create mode 100644 server/lib/auth-transaction.ts
diff --git a/app/components/Dialog/ChangePassword.vue b/app/components/Dialog/ChangePassword.vue
index 88f1018..c020b18 100644
--- a/app/components/Dialog/ChangePassword.vue
+++ b/app/components/Dialog/ChangePassword.vue
@@ -5,7 +5,7 @@ import { base64ToUint8Array, decrypt, deriveKey, encryptData, uint8ArrayToBase64
const emit = defineEmits(['confirm', 'cancel']);
const { user } = useAuth();
-const { providers } = await useModels();
+const { providers, refresh } = await useModels();
const INPUT_CLASS = 'w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)] placeholder:text-[var(--text-tertiary)]';
@@ -45,18 +45,19 @@ const importKey = (jwk: JsonWebKey) =>
// one. The server never sees the plaintext keys.
const decryptProviderKeys = async (
oldJwk: JsonWebKey,
-): Promise> => {
+): Promise> => {
const oldKey = await importKey(oldJwk);
- const decrypted: { id: string; plaintext: string }[] = [];
+ const decrypted: { id: string; originalApiKey: string; plaintext: string }[] = [];
const failed: string[] = [];
for (const provider of providers.value) {
if (!provider.config.apiKey) continue;
try {
- const plaintext = await decrypt(oldKey, base64ToUint8Array(provider.config.apiKey));
- decrypted.push({ id: provider.id, plaintext });
+ const originalApiKey = provider.config.apiKey;
+ const plaintext = await decrypt(oldKey, base64ToUint8Array(originalApiKey));
+ decrypted.push({ id: provider.id, originalApiKey, plaintext });
} catch {
failed.push(provider.name);
}
@@ -101,9 +102,9 @@ const submit = async () => {
steps[1]!.state = 'active';
const newJwk = await deriveKey(form.next, userId);
const newKey = await importKey(newJwk);
- const providerKeys: { id: string; apiKey: string }[] = [];
- for (const { id, plaintext } of decrypted.data) {
- providerKeys.push({ id, apiKey: uint8ArrayToBase64(await encryptData(newKey, plaintext)) });
+ const providerKeys: { id: string; originalApiKey: string; apiKey: string }[] = [];
+ for (const { id, originalApiKey, plaintext } of decrypted.data) {
+ providerKeys.push({ id, originalApiKey, apiKey: uint8ArrayToBase64(await encryptData(newKey, plaintext)) });
}
steps[1]!.state = 'done';
@@ -142,10 +143,7 @@ const submit = async () => {
// from here on this browser has to use the new key
localStorage.setItem('encryptionKey', JSON.stringify(newJwk));
- const newKeys = new Map(providerKeys.map(p => [p.id, p.apiKey]));
- providers.value = providers.value.map(p =>
- newKeys.has(p.id) ? { ...p, config: { ...p.config, apiKey: newKeys.get(p.id)! } } : p
- );
+ await refresh();
// the server signs the other devices out as part of the same request
steps[2]!.state = 'done';
diff --git a/app/components/Dialog/RecoverAccount.vue b/app/components/Dialog/RecoverAccount.vue
index ccf12e2..694137a 100644
--- a/app/components/Dialog/RecoverAccount.vue
+++ b/app/components/Dialog/RecoverAccount.vue
@@ -13,7 +13,8 @@ const props = defineProps<{
const emit = defineEmits(['confirm', 'cancel']);
-// also turn two-factor off and sign every device out when the link is used
+// also turn two-factor off when the link is used, every device is signed out
+// either way
const resetSecurity = ref(true);
const busy = ref(false);
@@ -96,14 +97,15 @@ const revoke = async () => {
were given before.
-
+
This deletes their saved API keys
The keys are encrypted with the old password and nobody can decrypt them without it. They
are removed when the link is used, not when it is created. Chats, agents and files are
- kept.
+ not deleted. Using the link signs them out on all devices.
@@ -135,7 +137,7 @@ const revoke = async () => {
- Also reset two-factor and sign out all their sessions
+ Also reset two-factor sign-in
diff --git a/app/components/Dialog/TwoFactor.vue b/app/components/Dialog/TwoFactor.vue
index c4aadd2..0acd66d 100644
--- a/app/components/Dialog/TwoFactor.vue
+++ b/app/components/Dialog/TwoFactor.vue
@@ -88,7 +88,7 @@ const submitPassword = async () => {
if (action.value === 'enable') {
const { data, error: err } = await authClient.twoFactor.enable(body);
- if (err || !data) {
+ if (err || !data || data.method !== 'totp') {
error.value = describeError(err, 'Two-factor could not be set up. Try again.');
} else {
// nothing is turned on until a code from the app is verified
@@ -136,12 +136,9 @@ const verifyCode = async () => {
code.value = '';
busy.value = false;
- // the boxes are disabled while busy and can't take focus until now
await nextTick();
codeInputEl.value?.focus();
} else {
- // set here rather than by whoever opened the dialog, so it is right
- // even if the dialog gets dismissed on the last step
if (user.value) user.value = { ...user.value, twoFactorEnabled: true };
step.value = 'codes';
}
@@ -155,7 +152,7 @@ const numberedBackupCodes = computed(() =>
);
const startCheck = async () => {
- // a different code every time, so going back to peek at one is not enough
+ // a different code every time
checkIndex.value = Math.floor(Math.random() * backupCodes.value.length);
checkInput.value = '';
error.value = '';
diff --git a/app/components/Onboarding/ProviderForm.vue b/app/components/Onboarding/ProviderForm.vue
index 9a08a5d..1096944 100644
--- a/app/components/Onboarding/ProviderForm.vue
+++ b/app/components/Onboarding/ProviderForm.vue
@@ -97,10 +97,13 @@ const submit = async () => {
config.apiKey = uint8ArrayToBase64(await encryptData(cryptoKey, key));
}
- await $fetch(`/api/provider/${provider.id}`, {
+ const saved = await $fetch(`/api/provider/${provider.id}`, {
method: 'PATCH',
- body: { config, enabled: true },
+ body: { config, enabled: true, expectedUpdatedAt: new Date(provider.updatedAt).toISOString() },
});
+ providers.value = providers.value.map(p => p.id === provider.id
+ ? { ...p, config, enabled: true, updatedAt: new Date(saved.updatedAt) }
+ : p);
} catch (err) {
console.error('Failed to save provider:', err);
error.value = 'The provider could not be saved. Try again.';
@@ -146,6 +149,12 @@ const submit = async () => {
? { ...p, config, enabled: true, models: models.map(m => ({ ...m, enabled: m.enabled || enabledIds.has(m.id) })) }
: p);
+ if (!models.some(model => model.outputModalities.includes("text") && (model.enabled || enabledIds.has(model.id)))) {
+ error.value = "The models were fetched, but none could be enabled. Try again.";
+ busy.value = false;
+ return;
+ }
+
emit('done');
};
diff --git a/app/composables/useModels.ts b/app/composables/useModels.ts
index 01a4f8e..e95ecb0 100644
--- a/app/composables/useModels.ts
+++ b/app/composables/useModels.ts
@@ -134,7 +134,7 @@ export const useModels = async () => {
});
}
- const updateProvider = async (id: string, updates: Partial) => {
+ const updateProvider = async (id: string, updates: Partial, expectedUpdatedAt?: string) => {
const provider = providers.value.find(p => p.id === id);
if (!provider) return;
@@ -142,10 +142,14 @@ export const useModels = async () => {
await $fetch(`/api/provider/${id}`, {
method: 'PATCH',
- body: updates,
+ body: {
+ ...updates,
+ expectedUpdatedAt: expectedUpdatedAt ?? new Date(original.updatedAt).toISOString(),
+ },
onRequest() {
+ // a config update only carries the fields that changed
providers.value = providers.value.map(p =>
- p.id === id ? { ...p, ...updates } : p
+ p.id === id ? { ...p, ...updates, config: { ...p.config, ...updates.config } } : p
);
},
onRequestError() {
@@ -166,6 +170,7 @@ export const useModels = async () => {
return {
providers,
+ refresh,
allModels,
getModel,
getProvider,
diff --git a/app/pages/auth/login.vue b/app/pages/auth/login.vue
index bde3d3e..3dd1f3a 100644
--- a/app/pages/auth/login.vue
+++ b/app/pages/auth/login.vue
@@ -259,7 +259,7 @@ const submitTwoFactor = async () => {
Email
-
diff --git a/app/pages/auth/recover/[token].vue b/app/pages/auth/recover/[token].vue
index 3f6d9d9..5588281 100644
--- a/app/pages/auth/recover/[token].vue
+++ b/app/pages/auth/recover/[token].vue
@@ -96,7 +96,8 @@ const submit = async () => {
-
+
+
@@ -105,6 +106,8 @@ const submit = async () => {
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none">
+
+
Veridian
@@ -159,10 +162,15 @@ const submit = async () => {
Deleted: API keys for {{ recovery.keyProviders.join(', ') }}
+
+
+ Signed out: every device that is signed in to this account
+
- Reset: two-factor sign-in, all active sessions
+ Reset: two-factor sign-in
-
+
+
@@ -14,6 +15,8 @@ definePageMeta({
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none">
+
+
Veridian
diff --git a/app/pages/auth/register.vue b/app/pages/auth/register.vue
index 033c77b..44b1fbd 100644
--- a/app/pages/auth/register.vue
+++ b/app/pages/auth/register.vue
@@ -228,7 +228,7 @@ const submit = async () => {
Email
-
diff --git a/app/pages/auth/setup.vue b/app/pages/auth/setup.vue
index fb7874c..c80e8a6 100644
--- a/app/pages/auth/setup.vue
+++ b/app/pages/auth/setup.vue
@@ -136,7 +136,8 @@ const submit = async () => {
-
+
+
@@ -145,6 +146,8 @@ const submit = async () => {
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none">
+
+
Veridian
@@ -183,7 +186,7 @@ const submit = async () => {
Email
-
diff --git a/app/pages/settings/instance/users.vue b/app/pages/settings/instance/users.vue
index 22a0da0..0205533 100644
--- a/app/pages/settings/instance/users.vue
+++ b/app/pages/settings/instance/users.vue
@@ -131,7 +131,7 @@ const setRole = (user: UserRow, role: 'admin' | 'user') => {
confirmThen({
title: `Make ${user.name} an admin?`,
- message: 'Admins can create, disable and delete accounts, change roles and change who can register. They never see anyone else\'s chats, files or API keys.',
+ message: 'Admins can create, disable and delete accounts, change roles and change who can register.',
confirmLabel: 'Make admin',
}, change);
};
@@ -141,8 +141,6 @@ const signOutEverywhere = (user: UserRow) => run(user, `${user.name} could not b
return !error;
});
-// The dialog makes the link and shows it, nothing in the list changes. For an
-// account that has no password yet it is a new setup link instead.
const recoverAccount = (user: UserRow) => {
openDialog(DialogType.RecoverAccount, undefined, { user });
};
@@ -268,9 +266,11 @@ const openCreateUser = () => {
{{ u.name }}
- (you)
+ (you)
-
+
{{ u.email }}
@@ -308,7 +308,6 @@ const openCreateUser = () => {
-
{
- Admins manage accounts, not content: chats, agents, files and API keys of other users are never
- shown here. Invited accounts have no password until their owner opens the setup link. Disabled
- accounts can't sign in and nothing of theirs is deleted. Your own account is
- managed from Profile and Security, and you can't remove your own admin role.
+ Invited accounts have no password until their owner opens the setup link.
+ you manage your account from Profile and Security, you can't remove
+ your own admin role.
diff --git a/app/pages/settings/providers/[id].vue b/app/pages/settings/providers/[id].vue
index 34ea39e..f79001d 100644
--- a/app/pages/settings/providers/[id].vue
+++ b/app/pages/settings/providers/[id].vue
@@ -68,55 +68,73 @@ const toggleProvider = async () => {
});
};
-let apiKeyTimeout: NodeJS.Timeout | undefined;
+let apiKeyTimeout: ReturnType
| undefined;
+let proxyUrlTimeout: ReturnType | undefined;
+const configError = ref("");
-const updateApiKey = async (value: string) => {
- if (!provider.value) return;
+// Config saves run one after the other, and each reads the revision only when
+// it is sent. A save that was typed while another was still on its way would
+// otherwise carry the revision from before that one and be turned down.
+let configSaves: Promise = Promise.resolve();
- const key = await crypto.subtle.importKey(
- "jwk",
- JSON.parse(window.localStorage.getItem("encryptionKey")!),
- "AES-GCM",
- false,
- ["encrypt", "decrypt"]
- );
- const encypted = await encryptData(key, value);
+const saveConfig = (
+ id: string,
+ failure: string,
+ config: () => Promise<{ apiKey?: string; apiProxyUrl?: string }>,
+ expectedEncryptionKey?: string,
+) => {
+ configSaves = configSaves.then(async () => {
+ // A queued edit must not pair an old encryption key with a new revision.
+ if (expectedEncryptionKey !== undefined && localStorage.getItem("encryptionKey") !== expectedEncryptionKey) {
+ configError.value = failure;
+ return;
+ }
+ const current = providers.value.find(p => p.id === id);
+ if (!current) {
+ return;
+ }
+ configError.value = "";
+ try {
+ await updateProvider(id, { config: await config() }, new Date(current.updatedAt).toISOString());
+ } catch {
+ configError.value = failure;
+ }
+ });
+};
- if (apiKeyTimeout) {
- clearTimeout(apiKeyTimeout);
+const updateApiKey = (value: string) => {
+ const snapshot = provider.value;
+ const storedKey = localStorage.getItem("encryptionKey");
+ if (!snapshot || !storedKey) {
+ return;
}
-
- apiKeyTimeout = setTimeout(async () => {
- await updateProvider(provider.value!.id, {
- config: {
- ...provider.value!.config,
- apiKey: uint8ArrayToBase64(encypted),
- },
- });
+ clearTimeout(apiKeyTimeout);
+ apiKeyTimeout = setTimeout(() => {
+ saveConfig(snapshot.id, "The key could not be saved. Reload this page before trying again.", async () => {
+ const key = await crypto.subtle.importKey("jwk", JSON.parse(storedKey), "AES-GCM", false, ["encrypt", "decrypt"]);
+ const encrypted = await encryptData(key, value);
+ return { apiKey: uint8ArrayToBase64(encrypted) };
+ }, storedKey);
}, 700);
};
-let proxyUrlTimeout: NodeJS.Timeout | undefined;
-
-const updateProxyUrl = async (value: string) => {
- if (!provider.value) return;
-
+const updateProxyUrl = (value: string) => {
+ const snapshot = provider.value;
+ if (!snapshot) {
+ return;
+ }
apiProxyUrl.value = value;
-
- if (proxyUrlTimeout) {
- clearTimeout(proxyUrlTimeout);
- }
-
- proxyUrlTimeout = setTimeout(async () => {
- await updateProvider(provider.value!.id, {
- config: {
- ...provider.value!.config,
- apiProxyUrl: value,
- },
- });
+ clearTimeout(proxyUrlTimeout);
+ proxyUrlTimeout = setTimeout(() => {
+ saveConfig(snapshot.id, "The address could not be saved. Reload this page before trying again.", async () => ({ apiProxyUrl: value }));
}, 700);
};
+onUnmounted(() => {
+ clearTimeout(apiKeyTimeout);
+ clearTimeout(proxyUrlTimeout);
+});
+
const fetchingModels = ref(false);
const fetchModels = async () => {
@@ -407,6 +425,8 @@ const isFormValid = computed(() => {
+ {{ configError }}
+
Your API key is encrypted using
diff --git a/app/pages/settings/providers/index.vue b/app/pages/settings/providers/index.vue
index 948b115..9ae1a42 100644
--- a/app/pages/settings/providers/index.vue
+++ b/app/pages/settings/providers/index.vue
@@ -27,8 +27,7 @@ const openProvider = (id: string) => navigateTo(`/settings/providers/${id}`);
-
@@ -51,8 +50,7 @@ const openProvider = (id: string) => navigateTo(`/settings/providers/${id}`);
-
diff --git a/app/pages/setup/instance.vue b/app/pages/setup/instance.vue
index 973e689..2b266f6 100644
--- a/app/pages/setup/instance.vue
+++ b/app/pages/setup/instance.vue
@@ -83,13 +83,16 @@ const submit = async () => {
-
-
-
-
-
+
+
diff --git a/app/pages/setup/provider.vue b/app/pages/setup/provider.vue
index d21735f..ff1f8a8 100644
--- a/app/pages/setup/provider.vue
+++ b/app/pages/setup/provider.vue
@@ -15,13 +15,16 @@ const back = () => navigateTo('/setup/instance');
-
-
-
-
-
+
+
diff --git a/app/pages/welcome.vue b/app/pages/welcome.vue
index 616387e..587544f 100644
--- a/app/pages/welcome.vue
+++ b/app/pages/welcome.vue
@@ -9,13 +9,14 @@ const finish = () => navigateTo(to ?? '/');
-
-
-
-
-
+
+
@@ -25,8 +26,7 @@ const finish = () => navigateTo(to ?? '/');
- Veridian runs on your own API keys. Add one provider now and its models are ready when you
- land on Home.
+ Veridian runs on your own API keys. Add one now and it'll be ready for you're ready to get started.
diff --git a/drizzle/schema.ts b/drizzle/schema.ts
index 1864a28..ca9bc5a 100644
--- a/drizzle/schema.ts
+++ b/drizzle/schema.ts
@@ -385,7 +385,8 @@ export const recoveryLinks = pgTable(
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
- // also turn two-factor off and sign every device out when it is used
+ // also turn two-factor off when it is used, every device is signed out
+ // either way
resetSecurity: boolean("reset_security").notNull().default(true),
expiresAt: timestamptz("expires_at").notNull(),
createdAt: timestamptz("created_at").defaultNow().notNull(),
diff --git a/lib/auth.ts b/lib/auth.ts
index 5663681..587d5f3 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -1,5 +1,4 @@
import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2";
-import { db } from "../server/lib/db";
import { APIError, createAuthMiddleware, getSessionFromCtx, isAPIError } from 'better-auth/api';
import { betterAuth } from 'better-auth/minimal';
import { createAccessControl } from 'better-auth/plugins/access';
@@ -8,23 +7,26 @@ import { defaultStatements } from 'better-auth/plugins/admin/access';
import { twoFactor } from 'better-auth/plugins/two-factor';
import * as schema from '../drizzle/schema';
import { getRegistrationMode } from '../server/utils/instance';
-import { claimInvite, INVITE_TOKEN_HEADER, releaseInvite } from '../server/utils/invites';
+import { hasUsableAdministrator } from '../server/lib/account-security';
+import { authDatabase, currentTransaction } from '../server/lib/auth-transaction';
+import { claimInvite, INVITE_TOKEN_HEADER } from '../server/utils/invites';
+import { isSetupNeeded } from '../server/utils/setup';
const MIN_PASSWORD_LENGTH = 8;
+const ADMIN_MUTATIONS = new Set(['/admin/set-role', '/admin/ban-user', '/admin/unban-user', '/admin/update-user']);
+
+const transactionRequired = () => new APIError('INTERNAL_SERVER_ERROR', {
+ message: 'This has to run in a transaction',
+ code: 'TRANSACTION_REQUIRED',
+});
+
const accessControl = createAccessControl(defaultStatements);
-// Admins manage accounts, not content. Signing in as someone else and setting
-// another user's password or email are left out on purpose: the first would
-// show their chats, the others would lock them out of their encrypted API keys.
-// Deleting is left out too, because Better Auth would only remove the account
-// and leave its agents, topics and files behind. Creating is left out so new
-// accounts are always made without a password and with a setup link.
-// /api/admin/users does both.
const roles = {
admin: accessControl.newRole({
- user: ['list', 'set-role', 'ban', 'get', 'update'],
- session: ['list', 'revoke', 'delete'],
+ user: ['set-role', 'ban'],
+ session: ['revoke'],
}),
user: accessControl.newRole({
user: [],
@@ -33,7 +35,7 @@ const roles = {
};
export const auth = betterAuth({
- database: drizzleAdapter(db, {
+ database: drizzleAdapter(authDatabase, {
provider: "pg",
schema,
usePlural: true,
@@ -44,7 +46,7 @@ export const auth = betterAuth({
updateAge: 60 * 60 * 24 * 1, // 1 day
cookieCache: {
enabled: true,
- maxAge: 5 * 60,
+ maxAge: 60,
},
},
@@ -53,8 +55,21 @@ export const auth = betterAuth({
hooks: {
before: createAuthMiddleware(async (ctx) => {
- // Who may register is an instance setting admins change at runtime.
+ if (ctx.path === "/change-password" && ctx.request) {
+ throw new APIError("FORBIDDEN", {
+ message: "Use the account password endpoint",
+ code: "USE_ACCOUNT_PASSWORD_ENDPOINT",
+ });
+ }
+
if (ctx.path === '/sign-up/email') {
+ if (await isSetupNeeded()) {
+ throw new APIError('FORBIDDEN', {
+ message: 'This instance has not been set up yet',
+ code: 'SETUP_NEEDED',
+ });
+ }
+
const mode = await getRegistrationMode();
if (mode === 'closed') {
@@ -65,9 +80,12 @@ export const auth = betterAuth({
}
if (mode === 'invite') {
+ const tx = currentTransaction();
+ if (!tx) throw transactionRequired();
+
const token = ctx.headers?.get(INVITE_TOKEN_HEADER);
- if (!token || !await claimInvite(token)) {
+ if (!token || !await claimInvite(tx, token)) {
throw new APIError('FORBIDDEN', {
message: 'This invite link is not valid anymore',
code: 'INVITE_INVALID',
@@ -76,9 +94,6 @@ export const auth = betterAuth({
}
}
- // Creating a user as an admin skips the sign up password rules. No
- // password at all is fine: that account can't be signed in to until
- // its owner sets one with a setup link.
if (ctx.path === '/admin/create-user' && ctx.body?.password !== undefined) {
const password = ctx.body.password;
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
@@ -89,12 +104,13 @@ export const auth = betterAuth({
}
}
- // The admin endpoints already stop admins from removing or disabling
- // themselves. Giving up their own admin role is the one way left to
- // end up with an instance nobody can manage, so that is refused too.
- if (ctx.path !== '/admin/set-role' && ctx.path !== '/admin/update-user') return;
+ if (!ADMIN_MUTATIONS.has(ctx.path)) return;
- const role = ctx.path === '/admin/set-role' ? ctx.body?.role : ctx.body?.data?.role;
+ if (!currentTransaction()) throw transactionRequired();
+
+ if (ctx.path !== '/admin/set-role') return;
+
+ const role = ctx.body?.role;
if (role === undefined) return;
const roleList = (Array.isArray(role) ? role : String(role).split(',')).map((r) => String(r).trim());
@@ -109,14 +125,17 @@ export const auth = betterAuth({
}
}),
- // The invite was claimed before the account was made. When making it
- // then fails, say because the email is taken, the use is given back.
after: createAuthMiddleware(async (ctx) => {
- if (ctx.path !== '/sign-up/email' || !isAPIError(ctx.context.returned)) return;
+ if (!ADMIN_MUTATIONS.has(ctx.path) || isAPIError(ctx.context.returned)) return;
- const token = ctx.headers?.get(INVITE_TOKEN_HEADER);
- if (token && await getRegistrationMode() === 'invite') {
- await releaseInvite(token);
+ const tx = currentTransaction();
+ if (!tx) throw transactionRequired();
+
+ if (!await hasUsableAdministrator(tx)) {
+ throw new APIError('CONFLICT', {
+ message: 'The instance must keep a usable administrator',
+ code: 'LAST_ADMIN',
+ });
}
}),
},
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 6c1146e..df8b366 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -119,7 +119,7 @@ export default defineNuxtConfig({
'@nuxt/hints/runtime/hydration/component',
'@tanstack/vue-virtual',
'better-auth/vue',
- 'uqr',
+ 'uqr',
'nanoid',
'drizzle-orm/pg-core'
],
@@ -167,7 +167,6 @@ export default defineNuxtConfig({
},
public: {
- disableSignup: process.env.DISABLE_SIGNUP === 'true' || process.env.DISABLE_SIGNUP === '1',
publicUrl: process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000',
}
},
diff --git a/scripts/recover.ts b/scripts/recover.ts
index 1ae92f1..3ad0206 100644
--- a/scripts/recover.ts
+++ b/scripts/recover.ts
@@ -1,65 +1,60 @@
-/**
- * Prints a one-time recovery link for an account. Meant for whoever runs the
- * server when nobody can sign in as an admin anymore:
- *
- * bun run recover
- *
- * It only talks to the database, the server does not have to be running. The
- * link works like the ones admins make under Settings → Users: it lets the
- * account set a new password, deletes its saved API keys, turns two-factor
- * off and signs every device out.
- *
- * Kept free of imports from the app so it also runs in the production image.
- * The link has to be made the same way as in server/utils/recovery.ts.
- */
-import { createHash, randomBytes, randomUUID } from 'node:crypto';
-import { Pool } from 'pg';
+import { createHash, randomBytes, randomUUID } from "node:crypto";
+import { Pool } from "pg";
const LIFETIME_MS = 24 * 60 * 60 * 1000;
+// Standalone so operators can recover an account without running the server.
const main = async (): Promise => {
const email = process.argv[2]?.trim().toLowerCase();
-
if (!email) {
- console.error('Usage: bun run recover ');
+ console.error("Usage: bun run recover ");
return 1;
}
const pool = new Pool({
- connectionString: process.env.DATABASE_URL || 'postgres://postgres:password@localhost:5432/veridian',
+ connectionString: process.env.DATABASE_URL || "postgres://postgres:password@localhost:5432/veridian",
});
+ const client = await pool.connect();
+ try {
+ await client.query("BEGIN");
+ // Same user-row lock as the web issuance and redemption endpoints.
+ const found = await client.query<{ id: string; name: string }>(
+ "SELECT id, name FROM users WHERE lower(email) = $1 FOR UPDATE", [email],
+ );
+ const user = found.rows[0];
+ if (!user) {
+ await client.query("ROLLBACK");
+ console.error(`No account with the email ${email}.`);
+ return 1;
+ }
- const found = await pool.query('SELECT id, name FROM users WHERE lower(email) = $1', [email]);
- const user = found.rows[0] as { id: string; name: string } | undefined;
+ const token = randomBytes(32).toString("base64url");
+ const tokenHash = createHash("sha256").update(token).digest("hex");
+ const expiresAt = new Date(Date.now() + LIFETIME_MS);
- if (!user) {
- console.error(`No account with the email ${email}.`);
- await pool.end();
+ await client.query("DELETE FROM recovery_links WHERE user_id = $1", [user.id]);
+ await client.query(
+ "INSERT INTO recovery_links (id, token_hash, user_id, reset_security, expires_at) VALUES ($1, $2, $3, true, $4)",
+ [randomUUID(), tokenHash, user.id, expiresAt],
+ );
+ await client.query("COMMIT");
+
+ const baseUrl = (process.env.NUXT_PUBLIC_URL ?? "http://localhost:3000").replace(/\/$/, "");
+ console.log(`Recovery link for ${user.name} <${email}>:`);
+ console.log("");
+ console.log(` ${baseUrl}/auth/recover/${token}`);
+ console.log("");
+ console.log("It works once and expires in 24 hours. Using it deletes the saved API keys");
+ console.log("of this account, turns two-factor off and signs every device out.");
+ return 0;
+ } catch (error) {
+ await client.query("ROLLBACK");
+ console.error("Failed to create recovery link:", error);
return 1;
+ } finally {
+ client.release();
+ await pool.end();
}
-
- const token = randomBytes(32).toString('base64url');
- const tokenHash = createHash('sha256').update(token).digest('hex');
- const expiresAt = new Date(Date.now() + LIFETIME_MS);
-
- // only one link works at a time
- await pool.query('DELETE FROM recovery_links WHERE user_id = $1', [user.id]);
- await pool.query(
- 'INSERT INTO recovery_links (id, token_hash, user_id, reset_security, expires_at) VALUES ($1, $2, $3, true, $4)',
- [randomUUID(), tokenHash, user.id, expiresAt],
- );
- await pool.end();
-
- const baseUrl = (process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000').replace(/\/$/, '');
-
- console.log(`Recovery link for ${user.name} <${email}>:`);
- console.log('');
- console.log(` ${baseUrl}/auth/recover/${token}`);
- console.log('');
- console.log('It works once and expires in 24 hours. Using it deletes the saved API keys');
- console.log('of this account, turns two-factor off and signs every device out.');
-
- return 0;
};
process.exit(await main());
diff --git a/server/api/account/index.delete.ts b/server/api/account/index.delete.ts
index 3f8155a..ba69b62 100644
--- a/server/api/account/index.delete.ts
+++ b/server/api/account/index.delete.ts
@@ -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,
});
}
diff --git a/server/api/account/password.post.ts b/server/api/account/password.post.ts
index 4db3472..0e47ec3 100644
--- a/server/api/account/password.post.ts
+++ b/server/api/account/password.post.ts
@@ -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 };
});
diff --git a/server/api/account/sessions/[id].delete.ts b/server/api/account/sessions/[id].delete.ts
index b76061b..228e06c 100644
--- a/server/api/account/sessions/[id].delete.ts
+++ b/server/api/account/sessions/[id].delete.ts
@@ -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')!;
diff --git a/server/api/account/sessions/index.get.ts b/server/api/account/sessions/index.get.ts
index 59ff7d5..1e02179 100644
--- a/server/api/account/sessions/index.get.ts
+++ b/server/api/account/sessions/index.get.ts
@@ -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: {
diff --git a/server/api/admin/users/[id]/index.delete.ts b/server/api/admin/users/[id]/index.delete.ts
index 0fe1021..9499985 100644
--- a/server/api/admin/users/[id]/index.delete.ts
+++ b/server/api/admin/users/[id]/index.delete.ts
@@ -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 };
});
diff --git a/server/api/admin/users/[id]/recovery.post.ts b/server/api/admin/users/[id]/recovery.post.ts
index 582ccbc..ba0c094 100644
--- a/server/api/admin/users/[id]/recovery.post.ts
+++ b/server/api/admin/users/[id]/recovery.post.ts
@@ -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),
diff --git a/server/api/admin/users/[id]/two-factor.delete.ts b/server/api/admin/users/[id]/two-factor.delete.ts
index 2c3f75a..21f7fdf 100644
--- a/server/api/admin/users/[id]/two-factor.delete.ts
+++ b/server/api/admin/users/[id]/two-factor.delete.ts
@@ -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));
});
diff --git a/server/api/auth/[...all].ts b/server/api/auth/[...all].ts
index 065142d..a6a3e3b 100644
--- a/server/api/auth/[...all].ts
+++ b/server/api/auth/[...all].ts
@@ -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;
+ }
});
diff --git a/server/api/files/[...key].get.ts b/server/api/files/[...key].get.ts
index 5a9bdce..de2b870 100644
--- a/server/api/files/[...key].get.ts
+++ b/server/api/files/[...key].get.ts
@@ -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;
}
diff --git a/server/api/invite/[token].get.ts b/server/api/invite/[token].get.ts
index fe51c12..7c8547e 100644
--- a/server/api/invite/[token].get.ts
+++ b/server/api/invite/[token].get.ts
@@ -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),
},
};
});
diff --git a/server/api/provider/[providerId]/index.patch.ts b/server/api/provider/[providerId]/index.patch.ts
index 84e29d8..a2b5400 100644
--- a/server/api/provider/[providerId]/index.patch.ts
+++ b/server/api/provider/[providerId]/index.patch.ts
@@ -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 };
-});
\ No newline at end of file
+ return { ok: true, updatedAt };
+ });
+});
diff --git a/server/api/recover/[token].post.ts b/server/api/recover/[token].post.ts
index ca60da4..0a9e1eb 100644
--- a/server/api/recover/[token].post.ts
+++ b/server/api/recover/[token].post.ts
@@ -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' };
diff --git a/server/api/setup/index.post.ts b/server/api/setup/index.post.ts
index 743945b..216479c 100644
--- a/server/api/setup/index.post.ts
+++ b/server/api/setup/index.post.ts
@@ -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})`);
diff --git a/server/lib/account-security.ts b/server/lib/account-security.ts
new file mode 100644
index 0000000..abff711
--- /dev/null
+++ b/server/lib/account-security.ts
@@ -0,0 +1,46 @@
+import type { Transaction } from "./auth-transaction";
+import { and, eq, isNotNull, ne, or, isNull, sql, like } from "drizzle-orm";
+import { accounts, users, verifications } from "../../drizzle/schema";
+
+const ADMIN_LOCK_ID = 7_261_009;
+
+// Deletion, banning and demotion must all hold this lock until commit.
+export const lockAdministration = async (tx: Transaction): Promise => {
+ await tx.execute(sql`select pg_advisory_xact_lock(${ADMIN_LOCK_ID})`);
+};
+
+export const hasUsableAdministrator = async (tx: Transaction, excludedUserId?: string): Promise => {
+ const rows = await tx.select({ id: users.id })
+ .from(users)
+ .innerJoin(accounts, eq(accounts.userId, users.id))
+ .where(and(
+ eq(users.role, "admin"),
+ excludedUserId === undefined ? undefined : ne(users.id, excludedUserId),
+ or(eq(users.banned, false), isNull(users.banned)),
+ eq(accounts.providerId, "credential"),
+ isNotNull(accounts.password),
+ ne(accounts.password, ""),
+ ))
+ .limit(1);
+
+ return rows.length > 0;
+};
+
+// All credential, recovery-link and provider-config writes use the user row
+// as their common lock, including when no credential or recovery row exists.
+export const lockAccount = async (tx: Transaction, userId: string): Promise => {
+ const rows = await tx.select({ id: users.id }).from(users)
+ .where(eq(users.id, userId)).for("update");
+
+ return rows.length > 0;
+};
+
+export const revokeTwoFactorTrust = async (tx: Transaction, userId: string): Promise => {
+ await tx.delete(verifications).where(and(
+ eq(verifications.value, userId),
+ or(
+ like(verifications.identifier, "trust-device-%"),
+ like(verifications.identifier, "2fa-%"),
+ ),
+ ));
+};
diff --git a/server/lib/auth-transaction.ts b/server/lib/auth-transaction.ts
new file mode 100644
index 0000000..f86d79b
--- /dev/null
+++ b/server/lib/auth-transaction.ts
@@ -0,0 +1,30 @@
+import { AsyncLocalStorage } from "node:async_hooks";
+import { db } from "./db";
+
+export type Transaction = Parameters[0]>[0];
+
+const storage = new AsyncLocalStorage();
+
+/** The transaction opened by authTransaction() around the running code, if any. */
+export const currentTransaction = (): Transaction | undefined => storage.getStore();
+
+// What the one auth instance is given as its database. Every access goes to
+// the transaction opened by authTransaction() around the running code, or to
+// the pool when there is none, so no second auth instance is needed to make
+// Better Auth write on a transaction's connection.
+export const authDatabase = new Proxy(db, {
+ get(_target, property) {
+ const target = storage.getStore() ?? db;
+ const value = Reflect.get(target, property, target);
+
+ return typeof value === "function" ? value.bind(target) : value;
+ },
+});
+
+/**
+ * Runs the callback in a transaction that everything the auth instance does
+ * inside it joins: its endpoints, its hooks and its internal adapter all
+ * commit or roll back together with the callback's own writes.
+ */
+export const authTransaction = (callback: (tx: Transaction) => Promise): Promise =>
+ db.transaction(tx => storage.run(tx, () => callback(tx)));
diff --git a/server/utils/account.ts b/server/utils/account.ts
index 2aa099e..84d9f4b 100644
--- a/server/utils/account.ts
+++ b/server/utils/account.ts
@@ -10,6 +10,8 @@ import {
users,
} from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
+import { hasUsableAdministrator, lockAccount, lockAdministration } from "~~/server/lib/account-security";
+import { Err, Ok } from "~~/types/result";
/**
* Deletes an account and everything that belongs to it.
@@ -21,7 +23,15 @@ import { db } from '~~/server/lib/db';
* Objects in file storage are left alone on purpose.
*/
export const deleteAccount = async (userId: string) => {
- await db.transaction(async (tx) => {
+ return await db.transaction(async (tx) => {
+ await lockAdministration(tx);
+ await lockAccount(tx, userId);
+
+ const [account] = await tx.select({ role: users.role }).from(users).where(eq(users.id, userId));
+ if (account?.role === "admin" && !await hasUsableAdministrator(tx, userId)) {
+ return Err("LAST_ADMIN" as const);
+ }
+
// 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));
@@ -35,5 +45,6 @@ export const deleteAccount = async (userId: string) => {
// sessions, accounts and two-factor rows cascade from the user
await tx.delete(users).where(eq(users.id, userId));
+ return Ok(undefined);
});
};
diff --git a/server/utils/admin.ts b/server/utils/admin.ts
index 8a2ba72..a751491 100644
--- a/server/utils/admin.ts
+++ b/server/utils/admin.ts
@@ -1,26 +1,20 @@
import type { H3Event } from 'h3';
-import { db } from '~~/server/lib/db';
/**
* Like protectRoute(), but only lets admins through.
*
- * The role is read from the database: the session cookie keeps a copy of the
- * user for a few minutes, and someone who just stopped being an admin should
- * not keep the access for that long.
+ * protectRoute() reads the user from the database on every request, so someone
+ * who just stopped being an admin loses the access right away.
*/
export const protectAdminRoute = async (event: H3Event) => {
- await protectRoute(event);
+ const sessionData = await protectRoute(event);
- const account = await db.query.users.findFirst({
- where: {
- id: event.context.user!.id as string,
- },
- });
-
- if (account?.role !== 'admin' || account.banned) {
+ if (sessionData.user.role !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Forbidden',
});
}
+
+ return sessionData;
};
diff --git a/server/utils/auth.ts b/server/utils/auth.ts
index 5c8c27d..5b4c628 100644
--- a/server/utils/auth.ts
+++ b/server/utils/auth.ts
@@ -2,9 +2,12 @@ import type { H3Event } from 'h3';
import { auth } from '~~/lib/auth';
export const protectRoute = async (event: H3Event) => {
- const sessionData = await auth.api.getSession({ headers: event.headers });
+ const sessionData = await auth.api.getSession({
+ headers: event.headers,
+ query: { disableCookieCache: true },
+ });
- if (sessionData === null) {
+ if (sessionData === null || sessionData.user.banned) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
@@ -12,4 +15,5 @@ export const protectRoute = async (event: H3Event) => {
}
event.context.user = sessionData.user;
+ return sessionData;
};
diff --git a/server/utils/invites.ts b/server/utils/invites.ts
index b9dc258..12e6c6f 100644
--- a/server/utils/invites.ts
+++ b/server/utils/invites.ts
@@ -1,5 +1,6 @@
import { and, eq, gt, lt, lte, or, sql } from 'drizzle-orm';
import { invites, users } from '../../drizzle/schema';
+import type { Transaction } from '../lib/auth-transaction';
import { db } from '../lib/db';
export const INVITE_TOKEN_HEADER = 'x-invite-token';
@@ -9,10 +10,12 @@ const usable = () => and(lt(invites.uses, invites.maxUses), gt(invites.expiresAt
/**
* Takes one use of an invite. It is a single statement, so two people
- * registering with the same single use link at once cannot both get in.
+ * registering with the same single use link at once cannot both get in. It
+ * runs on the transaction of the registration, a failed one gives the use back
+ * by rolling back.
*/
-export const claimInvite = async (token: string): Promise => {
- const claimed = await db.update(invites)
+export const claimInvite = async (tx: Transaction, token: string): Promise => {
+ const claimed = await tx.update(invites)
.set({ uses: sql`${invites.uses} + 1` })
.where(and(eq(invites.token, token), usable()))
.returning({ id: invites.id });
@@ -20,13 +23,6 @@ export const claimInvite = async (token: string): Promise => {
return claimed.length > 0;
};
-/** Gives a use back when the registration it was taken for did not go through. */
-export const releaseInvite = async (token: string) => {
- await db.update(invites)
- .set({ uses: sql`${invites.uses} - 1` })
- .where(and(eq(invites.token, token), gt(invites.uses, 0)));
-};
-
export const findUsableInvite = async (token: string) => {
const rows = await db
.select({
diff --git a/server/utils/recovery.ts b/server/utils/recovery.ts
index 8075ac6..5abae1c 100644
--- a/server/utils/recovery.ts
+++ b/server/utils/recovery.ts
@@ -1,7 +1,8 @@
import { createHash, randomBytes } from 'node:crypto';
-import { and, eq, gt, lte, sql } from 'drizzle-orm';
+import { and, eq, gt, lte } from 'drizzle-orm';
import { accounts, recoveryLinks, users } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
+import { lockAccount } from "~~/server/lib/account-security";
export const RECOVERY_LINK_LIFETIME_MS = 24 * 60 * 60 * 1000;
// a new person may take a while to get round to it
@@ -36,7 +37,9 @@ export const createRecoveryLink = async (userId: string, resetSecurity: boolean)
const expiresAt = new Date(Date.now() + lifetime);
await db.transaction(async (tx) => {
+ await lockAccount(tx, userId);
await tx.delete(recoveryLinks).where(eq(recoveryLinks.userId, userId));
+ // nothing else ever looks at a link that ran out, so they go here
await tx.delete(recoveryLinks).where(lte(recoveryLinks.expiresAt, new Date()));
await tx.insert(recoveryLinks).values({
tokenHash: hashRecoveryToken(token),
diff --git a/server/utils/setup.ts b/server/utils/setup.ts
index 058dfe4..a2f3f7c 100644
--- a/server/utils/setup.ts
+++ b/server/utils/setup.ts
@@ -1,5 +1,5 @@
-import { users } from '~~/drizzle/schema';
-import { db } from '~~/server/lib/db';
+import { users } from '../../drizzle/schema';
+import { db } from '../lib/db';
/**
* An instance needs setting up for as long as it has no accounts. The first