Files
veridian/server/utils/recovery.ts
T

70 lines
2.6 KiB
TypeScript

import { createHash, randomBytes } from 'node:crypto';
import { and, eq, gt, lte, sql } from 'drizzle-orm';
import { accounts, recoveryLinks, users } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
export const RECOVERY_LINK_LIFETIME_MS = 24 * 60 * 60 * 1000;
// a new person may take a while to get round to it
export const SETUP_LINK_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
/**
* An account an admin created has no password until its owner sets one with
* a setup link. Until then nobody can sign in to it.
*/
export const hasPassword = async (userId: string): Promise<boolean> => {
const rows = await db
.select({ id: accounts.id })
.from(accounts)
.where(and(eq(accounts.userId, userId), eq(accounts.providerId, 'credential')))
.limit(1);
return rows.length > 0;
};
// scripts/recover.ts makes links the same way, keep the two in step
export const hashRecoveryToken = (token: string) => createHash('sha256').update(token).digest('hex');
/**
* Makes a one-time link a user can set a password with and returns its token:
* a recovery link for someone who forgot theirs, a setup link for an account
* that never had one. Only one link works at a time, making a new one cancels
* the ones before it.
*/
export const createRecoveryLink = async (userId: string, resetSecurity: boolean) => {
const token = randomBytes(32).toString('base64url');
const lifetime = await hasPassword(userId) ? RECOVERY_LINK_LIFETIME_MS : SETUP_LINK_LIFETIME_MS;
const expiresAt = new Date(Date.now() + lifetime);
await db.transaction(async (tx) => {
await tx.delete(recoveryLinks).where(eq(recoveryLinks.userId, userId));
await tx.delete(recoveryLinks).where(lte(recoveryLinks.expiresAt, new Date()));
await tx.insert(recoveryLinks).values({
tokenHash: hashRecoveryToken(token),
userId,
resetSecurity,
expiresAt,
});
});
return { token, expiresAt };
};
export const findRecoveryLink = async (token: string) => {
const rows = await db
.select({
id: recoveryLinks.id,
userId: recoveryLinks.userId,
resetSecurity: recoveryLinks.resetSecurity,
expiresAt: recoveryLinks.expiresAt,
email: users.email,
name: users.name,
twoFactorEnabled: users.twoFactorEnabled,
})
.from(recoveryLinks)
.innerJoin(users, eq(users.id, recoveryLinks.userId))
.where(and(eq(recoveryLinks.tokenHash, hashRecoveryToken(token)), gt(recoveryLinks.expiresAt, new Date())))
.limit(1);
return rows[0] ?? null;
};