73 lines
2.8 KiB
TypeScript
73 lines
2.8 KiB
TypeScript
import { createHash, randomBytes } from 'node:crypto';
|
|
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
|
|
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 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),
|
|
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;
|
|
};
|