migration to postgres
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
||||
const token = body.token as string;
|
||||
const email = body.email as string;
|
||||
|
||||
const userCount = getUserCount();
|
||||
const userCount = await getUserCount();
|
||||
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
|
||||
|
||||
if (!token) {
|
||||
|
||||
@@ -13,7 +13,7 @@ export const changePasswordHandler: Handler = async function (ctx) {
|
||||
if (isProduction) validatePassword(newPassword);
|
||||
const reqUser = ctx.get('user');
|
||||
|
||||
const dbUser = getUserById(reqUser.id);
|
||||
const dbUser = await getUserById(reqUser.id);
|
||||
|
||||
if (!dbUser) throw errors.UNAUTHORIZED();
|
||||
|
||||
@@ -23,8 +23,7 @@ export const changePasswordHandler: Handler = async function (ctx) {
|
||||
}
|
||||
|
||||
const newPasswordHash = await argon2.hash(newPassword);
|
||||
// Use floored seconds-to-ms so the token iat (also floored) is never behind
|
||||
const passwordChangedAt = Math.floor(Date.now() / 1000) * 1000;
|
||||
const passwordChangedAt = new Date();
|
||||
await updateUser(reqUser.id, { password: newPasswordHash, passwordChangedAt });
|
||||
|
||||
const { id, email, name, role } = reqUser;
|
||||
|
||||
@@ -7,7 +7,7 @@ export const forgotPasswordHandler: Handler = async function (ctx) {
|
||||
const { email } = ctx.get('body');
|
||||
const origin = ctx.get('origin');
|
||||
|
||||
const dbUser = getUserByEmail(email);
|
||||
const dbUser = await getUserByEmail(email);
|
||||
|
||||
if (!dbUser) return ctx.json({ ok: true });
|
||||
const verificationCode = await sign({ id: dbUser.id, email, purpose: 'reset-password' }, '6h');
|
||||
|
||||
@@ -6,7 +6,7 @@ import { sign } from '../../jwt';
|
||||
import * as errors from '../../custom-errors';
|
||||
import {
|
||||
getUserByEmail,
|
||||
getPasskeysByEmailAndOrigin,
|
||||
getPasskeysByUserIdAndOrigin,
|
||||
getPasskeyByCredentialId,
|
||||
createPasskey,
|
||||
updatePasskey,
|
||||
@@ -41,8 +41,11 @@ const passkeyRouterPostChallenge: Handler = async (ctx) => {
|
||||
const origin = ctx.get('origin') as string;
|
||||
const rpId = getRpId(origin);
|
||||
|
||||
const dbUser = await getUserByEmail(email!);
|
||||
if (!dbUser) throw errors.NOT_FOUND('User not found');
|
||||
|
||||
// Get existing passkeys to exclude them
|
||||
const existingPasskeys = getPasskeysByEmailAndOrigin(email!, origin);
|
||||
const existingPasskeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: RP_NAME,
|
||||
@@ -59,7 +62,7 @@ const passkeyRouterPostChallenge: Handler = async (ctx) => {
|
||||
},
|
||||
});
|
||||
|
||||
await storeChallenge(email!, origin, options.challenge);
|
||||
await storeChallenge(dbUser.id, origin, options.challenge, CHALLENGE_TTL_MS);
|
||||
return ctx.json(options);
|
||||
};
|
||||
passkeyRouter.post('/challenge/:email', passkeyRateLimiter, passkeyRouterPostChallenge);
|
||||
@@ -68,10 +71,10 @@ passkeyRouter.post('/challenge/:email', passkeyRateLimiter, passkeyRouterPostCha
|
||||
const passkeyRouterPost: Handler = async (ctx) => {
|
||||
const origin = ctx.get('origin') as string;
|
||||
const rpId = getRpId(origin);
|
||||
const { email } = ctx.get('user') as User;
|
||||
const user = ctx.get('user') as User;
|
||||
const response = ctx.get('body') as RegistrationResponseJSON;
|
||||
|
||||
const storedChallenge = await consumeChallenge(email, origin, CHALLENGE_TTL_MS);
|
||||
const storedChallenge = await consumeChallenge(user.id, origin);
|
||||
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
@@ -88,7 +91,7 @@ const passkeyRouterPost: Handler = async (ctx) => {
|
||||
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
||||
|
||||
await createPasskey({
|
||||
email,
|
||||
userId: user.id,
|
||||
origin,
|
||||
credentialId: credential.id,
|
||||
publicKey: Buffer.from(credential.publicKey).toString('base64'),
|
||||
@@ -105,7 +108,10 @@ const passkeyRouterGet: Handler = async (ctx) => {
|
||||
const origin = ctx.get('origin') as string;
|
||||
const rpId = getRpId(origin);
|
||||
|
||||
const passkeys = getPasskeysByEmailAndOrigin(email!, origin);
|
||||
const dbUser = await getUserByEmail(email!);
|
||||
if (!dbUser) throw errors.NOT_FOUND('User not found');
|
||||
|
||||
const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
@@ -115,7 +121,7 @@ const passkeyRouterGet: Handler = async (ctx) => {
|
||||
userVerification: 'preferred',
|
||||
});
|
||||
|
||||
await storeChallenge(email!, origin, options.challenge);
|
||||
await storeChallenge(dbUser.id, origin, options.challenge, CHALLENGE_TTL_MS);
|
||||
return ctx.json(options);
|
||||
};
|
||||
passkeyRouter.get('/signin/:email', passkeyRateLimiter, passkeyRouterGet);
|
||||
@@ -127,11 +133,14 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
|
||||
const rpId = getRpId(origin);
|
||||
const response = ctx.get('body') as AuthenticationResponseJSON;
|
||||
|
||||
const storedChallenge = await consumeChallenge(email!, origin, CHALLENGE_TTL_MS);
|
||||
const dbUser = await getUserByEmail(email!);
|
||||
if (!dbUser) throw errors.UNAUTHORIZED();
|
||||
|
||||
const storedChallenge = await consumeChallenge(dbUser.id, origin);
|
||||
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
|
||||
|
||||
// Find the passkey being used
|
||||
const dbPasskey = getPasskeyByCredentialId(email!, response.id);
|
||||
const dbPasskey = await getPasskeyByCredentialId(dbUser.id, response.id);
|
||||
|
||||
if (!dbPasskey || !dbPasskey.publicKey) throw errors.BAD_CREDENTIALS();
|
||||
|
||||
@@ -152,10 +161,7 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
|
||||
// Update counter to prevent replay attacks
|
||||
await updatePasskey(dbPasskey.id, { counter: verification.authenticationInfo.newCounter });
|
||||
|
||||
const dbUser = getUserByEmail(email!);
|
||||
if (!dbUser) throw errors.UNAUTHORIZED();
|
||||
|
||||
const passkeys = getPasskeysByEmailAndOrigin(email!, origin);
|
||||
const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
|
||||
|
||||
const { id, name, username, role } = dbUser;
|
||||
const token = await sign({
|
||||
|
||||
@@ -10,7 +10,7 @@ export const resendVerificationHandler: Handler = async function (ctx) {
|
||||
|
||||
if (!email || typeof email !== 'string') throw errors.BAD_REQUEST('Email is required');
|
||||
|
||||
const user = getUserByEmail(email);
|
||||
const user = await getUserByEmail(email);
|
||||
if (!user) throw errors.NOT_FOUND('User not found');
|
||||
if (user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified');
|
||||
|
||||
|
||||
@@ -7,13 +7,12 @@ import * as errors from '@@/custom-errors';
|
||||
import { validatePassword } from './validate-password';
|
||||
|
||||
export const resetPasswordHandler: Handler = async function (ctx) {
|
||||
const now = Date.now().valueOf();
|
||||
const { password, verificationCode } = ctx.get('body');
|
||||
validatePassword(password);
|
||||
const userInfo = (await verify(verificationCode)) as User;
|
||||
if (!userInfo) throw errors.UNAUTHORIZED();
|
||||
const passwordHash = await argon2.hash(password);
|
||||
await updateUser(userInfo.id, { password: passwordHash, status: 'Active', passwordChangedAt: now });
|
||||
await updateUser(userInfo.id, { password: passwordHash, status: 'Active', passwordChangedAt: new Date() });
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { getUserByEmail, getPasskeysByEmailAndOrigin } from 'officerdb';
|
||||
import { getUserByEmail, getPasskeysByUserIdAndOrigin } from 'officerdb';
|
||||
import { sign } from '@@/jwt';
|
||||
import { getClaudeDir } from '@@/data-path';
|
||||
import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config';
|
||||
@@ -13,11 +13,12 @@ const TEST_USERS: number[] = [];
|
||||
export const signinHandler: Handler = async function (ctx) {
|
||||
const { email, password } = ctx.get('body');
|
||||
const origin = ctx.get('origin');
|
||||
const dbUser = getUserByEmail(email);
|
||||
const dbUser = await getUserByEmail(email);
|
||||
if (!dbUser) throw errors.UNAUTHORIZED();
|
||||
|
||||
const passkeys = getPasskeysByEmailAndOrigin(email, origin);
|
||||
const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
|
||||
|
||||
if (!dbUser || !dbUser.password) throw errors.UNAUTHORIZED();
|
||||
if (!dbUser.password) throw errors.UNAUTHORIZED();
|
||||
const { status } = dbUser;
|
||||
if (status !== 'Active') throw errors.UNAUTHORIZED();
|
||||
const isValidPassword = TEST_USERS.includes(dbUser.id) || (await argon2.verify(dbUser.password, password));
|
||||
|
||||
@@ -13,7 +13,7 @@ export const signupHandler: Handler = async function (ctx) {
|
||||
throw errors.BAD_REQUEST('Invalid email address');
|
||||
}
|
||||
|
||||
const userCount = getUserCount();
|
||||
const userCount = await getUserCount();
|
||||
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
|
||||
|
||||
const dbUser = await createUser({
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { Handler } from 'hono';
|
||||
import type { User } from 'types';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { getUserById, getPasskeysByEmailAndOrigin } from 'officerdb';
|
||||
import { getUserById, getPasskeysByUserIdAndOrigin } from 'officerdb';
|
||||
|
||||
export const usersMe: Handler = async function (ctx) {
|
||||
const user = ctx.get('user') as User;
|
||||
const origin = ctx.get('origin') as string;
|
||||
|
||||
const dbUser = getUserById(user.id);
|
||||
const dbUser = await getUserById(user.id);
|
||||
|
||||
if (!dbUser) return errors.NOT_FOUND();
|
||||
|
||||
const passkeys = getPasskeysByEmailAndOrigin(dbUser.email, origin || '');
|
||||
const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin || '');
|
||||
const { password, ...userWithoutPassword } = dbUser;
|
||||
const returnUser = { ...userWithoutPassword, passkeyCount: passkeys.length };
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export const verifyTokenHandler: Handler = async function (ctx) {
|
||||
|
||||
if (!userInfo?.id) throw errors.BAD_REQUEST('Token is invalid or expired');
|
||||
|
||||
const user = getUserById(userInfo.id);
|
||||
const user = await getUserById(userInfo.id);
|
||||
if (!user) throw errors.NOT_FOUND('User not found');
|
||||
|
||||
// Reset-password tokens skip the verification status check
|
||||
|
||||
@@ -11,7 +11,7 @@ export const verifyHandler: Handler = async function (ctx) {
|
||||
const userInfo = (await verifyJwt(verificationCode)) as User;
|
||||
if (!userInfo) throw errors.BAD_REQUEST();
|
||||
|
||||
const user = getUserById(userInfo.id);
|
||||
const user = await getUserById(userInfo.id);
|
||||
if (!user) throw errors.NOT_FOUND('User not found');
|
||||
|
||||
const updates: Record<string, unknown> = { status: 'Active' };
|
||||
@@ -38,7 +38,7 @@ export const verifyHandler: Handler = async function (ctx) {
|
||||
await updateUser(userInfo.id, updates);
|
||||
|
||||
// Re-fetch user to get final values after update
|
||||
const finalUser = getUserById(userInfo.id);
|
||||
const finalUser = await getUserById(userInfo.id);
|
||||
if (!finalUser) throw errors.NOT_FOUND('User not found');
|
||||
|
||||
// Issue a token so the user is logged in immediately
|
||||
|
||||
Reference in New Issue
Block a user