file based auth

This commit is contained in:
2026-02-23 09:58:08 +00:00
parent 0bea406dcd
commit 2779fe22c6
40 changed files with 456 additions and 1010 deletions
+5 -7
View File
@@ -1,6 +1,6 @@
import type { Handler } from 'hono';
import { sendMail } from 'emailer';
import { officerdb, count, Users } from 'officerdb';
import { getUserCount, createUser } from 'officerdb';
import { sign, verify } from '@@/jwt';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
@@ -13,8 +13,7 @@ export const bootstrapHandler: Handler = async function (ctx) {
const token = body.token as string;
const email = body.email as string;
const result = await officerdb.select({ count: count() }).from(Users);
const userCount = result[0]?.count ?? 0;
const userCount = getUserCount();
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
if (!token) {
@@ -35,7 +34,7 @@ export const bootstrapHandler: Handler = async function (ctx) {
return ctx.json({ ok: true });
}
const payload = await verify(token).catch(() => null) as { email: string } | null;
const payload = ((await verify(token).catch(() => null)) as { email: string } | null);
if (!payload?.email) throw errors.BAD_REQUEST('Invalid or expired token');
const name = body.name as string;
@@ -50,16 +49,15 @@ export const bootstrapHandler: Handler = async function (ctx) {
const passwordHash = await argon2.hash(password);
const insertedUsers = await officerdb.insert(Users).values({
await createUser({
email: payload.email,
password: passwordHash,
name: name.trim(),
username: username.trim(),
role: 'Super Admin',
status: 'Active',
}).returning();
});
if (!insertedUsers || insertedUsers.length === 0) throw errors.INTERNAL_SERVER_ERROR('Failed to create user');
syncUserPiConfig(payload.email).catch(() => {});
return ctx.json({ ok: true });
};
+3 -6
View File
@@ -1,5 +1,5 @@
import type { Handler } from 'hono';
import { officerdb, eq, Users } from 'officerdb';
import { getUserById, updateUser } from 'officerdb';
import argon2 from 'argon2';
import { sign } from '@@/jwt';
import * as errors from '@@/custom-errors';
@@ -13,10 +13,7 @@ export const changePasswordHandler: Handler = async function (ctx) {
if (isProduction) validatePassword(newPassword);
const reqUser = ctx.get('user');
const dbUser = await officerdb.query.Users.findFirst({
where: eq(Users.id, reqUser.id),
columns: { password: true, username: true },
});
const dbUser = getUserById(reqUser.id);
if (!dbUser) throw errors.UNAUTHORIZED();
@@ -28,7 +25,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;
await officerdb.update(Users).set({ password: newPasswordHash, passwordChangedAt }).where(eq(Users.id, reqUser.id));
await updateUser(reqUser.id, { password: newPasswordHash, passwordChangedAt });
const { id, email, name, role } = reqUser;
const username = dbUser.username ?? reqUser.username;
+2 -4
View File
@@ -1,5 +1,5 @@
import type { Handler } from 'hono';
import { officerdb, eq, Users } from 'officerdb';
import { getUserByEmail } from 'officerdb';
import { sign } from '@@/jwt';
import { sendMail } from 'emailer';
@@ -7,9 +7,7 @@ export const forgotPasswordHandler: Handler = async function (ctx) {
const { email } = ctx.get('body');
const origin = ctx.get('origin');
const dbUser = await officerdb.query.Users.findFirst({
where: eq(Users.email, email),
});
const dbUser = getUserByEmail(email);
if (!dbUser) return ctx.json({ ok: true });
const verificationCode = await sign({ id: dbUser.id, email, purpose: 'reset-password' }, '6h');
+22 -66
View File
@@ -4,7 +4,15 @@ import { createRouter } from '../../create-router';
import { userMiddleware, passkeyRateLimiter } from '../../_middlewares';
import { sign } from '../../jwt';
import * as errors from '../../custom-errors';
import { officerdb, eq, and, lt, Passkeys, Users, PasskeyChallenges } from 'officerdb';
import {
getUserByEmail,
getPasskeysByEmailAndOrigin,
getPasskeyByCredentialId,
createPasskey,
updatePasskey,
storeChallenge,
consumeChallenge,
} from 'officerdb';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
@@ -27,45 +35,6 @@ function getRpId(origin: string): string {
}
}
async function storeChallenge(email: string, origin: string, challenge: string) {
await officerdb
.insert(PasskeyChallenges)
.values({ email, origin, challenge })
.onConflictDoUpdate({
target: [PasskeyChallenges.email, PasskeyChallenges.origin],
set: { challenge, createdAt: new Date() },
});
}
async function getAndDeleteChallenge(email: string, origin: string): Promise<string | null> {
const minValidTime = new Date(Date.now() - CHALLENGE_TTL_MS);
// Delete expired challenges for this email/origin
await officerdb
.delete(PasskeyChallenges)
.where(
and(
eq(PasskeyChallenges.email, email),
eq(PasskeyChallenges.origin, origin),
lt(PasskeyChallenges.createdAt, minValidTime),
),
);
// Get and delete the challenge in one operation
const result = await officerdb
.delete(PasskeyChallenges)
.where(and(eq(PasskeyChallenges.email, email), eq(PasskeyChallenges.origin, origin)))
.returning();
const entry = result[0];
if (!entry) return null;
// Double-check TTL (in case of race condition)
if (entry.createdAt < minValidTime) return null;
return entry.challenge;
}
// Generate registration options (challenge) for creating a new passkey
const passkeyRouterPostChallenge: Handler = async (ctx) => {
const { email } = ctx.req.param();
@@ -73,9 +42,7 @@ const passkeyRouterPostChallenge: Handler = async (ctx) => {
const rpId = getRpId(origin);
// Get existing passkeys to exclude them
const existingPasskeys = await officerdb.query.Passkeys.findMany({
where: and(eq(Passkeys.email, email!), eq(Passkeys.origin, origin)),
});
const existingPasskeys = getPasskeysByEmailAndOrigin(email!, origin);
const options = await generateRegistrationOptions({
rpName: RP_NAME,
@@ -104,7 +71,7 @@ const passkeyRouterPost: Handler = async (ctx) => {
const { email } = ctx.get('user') as User;
const response = ctx.get('body') as RegistrationResponseJSON;
const storedChallenge = await getAndDeleteChallenge(email, origin);
const storedChallenge = await consumeChallenge(email, origin, CHALLENGE_TTL_MS);
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
const verification = await verifyRegistrationResponse({
@@ -120,15 +87,14 @@ const passkeyRouterPost: Handler = async (ctx) => {
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
const values = {
await createPasskey({
email,
origin,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey).toString('base64'),
counter: credential.counter,
};
});
await officerdb.insert(Passkeys).values(values);
return ctx.json({ ok: true, credentialDeviceType, credentialBackedUp });
};
passkeyRouter.post('/credentials', userMiddleware, passkeyRouterPost);
@@ -139,9 +105,7 @@ const passkeyRouterGet: Handler = async (ctx) => {
const origin = ctx.get('origin') as string;
const rpId = getRpId(origin);
const passkeys = await officerdb.query.Passkeys.findMany({
where: and(eq(Passkeys.email, email!), eq(Passkeys.origin, origin)),
});
const passkeys = getPasskeysByEmailAndOrigin(email!, origin);
const options = await generateAuthenticationOptions({
rpID: rpId,
@@ -163,13 +127,11 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
const rpId = getRpId(origin);
const response = ctx.get('body') as AuthenticationResponseJSON;
const storedChallenge = await getAndDeleteChallenge(email!, origin);
const storedChallenge = await consumeChallenge(email!, origin, CHALLENGE_TTL_MS);
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
// Find the passkey being used
const dbPasskey = await officerdb.query.Passkeys.findFirst({
where: and(eq(Passkeys.email, email!), eq(Passkeys.credentialId, response.id)),
});
const dbPasskey = getPasskeyByCredentialId(email!, response.id);
if (!dbPasskey || !dbPasskey.publicKey) throw errors.BAD_CREDENTIALS();
@@ -188,27 +150,21 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
if (!verification.verified) throw errors.BAD_CREDENTIALS();
// Update counter to prevent replay attacks
await officerdb
.update(Passkeys)
.set({ counter: verification.authenticationInfo.newCounter })
.where(eq(Passkeys.id, dbPasskey.id));
const dbUser = await officerdb.query.Users.findFirst({
where: eq(Users.email, email!),
with: { passkeys: true },
});
await updatePasskey(dbPasskey.id, { counter: verification.authenticationInfo.newCounter });
const dbUser = getUserByEmail(email!);
if (!dbUser) throw errors.UNAUTHORIZED();
const passkeys = getPasskeysByEmailAndOrigin(email!, origin);
const { id, name, username, role } = dbUser;
const passkeys = dbUser.passkeys?.length ?? 0;
const token = await sign({
id,
email,
name,
username,
role,
passkeys,
passkeys: passkeys.length,
});
return ctx.json({
@@ -219,7 +175,7 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
name,
username,
role,
passkeys,
passkeys: passkeys.length,
},
});
};
+2 -4
View File
@@ -1,5 +1,5 @@
import type { Handler } from 'hono';
import { officerdb, eq, Users } from 'officerdb';
import { getUserByEmail } from 'officerdb';
import { sign } from '@@/jwt';
import * as errors from '@@/custom-errors';
import { sendMail } from 'emailer';
@@ -10,9 +10,7 @@ export const resendVerificationHandler: Handler = async function (ctx) {
if (!email || typeof email !== 'string') throw errors.BAD_REQUEST('Email is required');
const user = await officerdb.query.Users.findFirst({
where: eq(Users.email, email),
});
const user = getUserByEmail(email);
if (!user) throw errors.NOT_FOUND('User not found');
if (user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified');
+2 -5
View File
@@ -1,6 +1,6 @@
import type { User } from 'types';
import type { Handler } from 'hono';
import { officerdb, eq, Users } from 'officerdb';
import { updateUser } from 'officerdb';
import { verify } from '@@/jwt';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
@@ -13,10 +13,7 @@ export const resetPasswordHandler: Handler = async function (ctx) {
const userInfo = (await verify(verificationCode)) as User;
if (!userInfo) throw errors.UNAUTHORIZED();
const passwordHash = await argon2.hash(password);
await officerdb
.update(Users)
.set({ password: passwordHash, status: 'Active', passwordChangedAt: now })
.where(eq(Users.id, userInfo.id));
await updateUser(userInfo.id, { password: passwordHash, status: 'Active', passwordChangedAt: now });
return ctx.json({ ok: true });
};
+3 -7
View File
@@ -1,7 +1,7 @@
import type { Handler } from 'hono';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { officerdb, eq, and, Users, Passkeys } from 'officerdb';
import { getUserByEmail, getPasskeysByEmailAndOrigin } from 'officerdb';
import { sign } from '@@/jwt';
import { getClaudeDir } from '@@/data-path';
import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config';
@@ -13,13 +13,9 @@ const TEST_USERS: number[] = [];
export const signinHandler: Handler = async function (ctx) {
const { email, password } = ctx.get('body');
const origin = ctx.get('origin');
const dbUser = await officerdb.query.Users.findFirst({
where: eq(Users.email, email),
});
const dbUser = getUserByEmail(email);
const passkeys = await officerdb.query.Passkeys.findMany({
where: and(eq(Passkeys.email, email), eq(Passkeys.origin, origin)),
});
const passkeys = getPasskeysByEmailAndOrigin(email, origin);
if (!dbUser || !dbUser.password) throw errors.UNAUTHORIZED();
const { status } = dbUser;
+2 -15
View File
@@ -1,23 +1,10 @@
import type { Handler } from 'hono';
import { officerdb, TokenBlacklist, lt } from 'officerdb';
// Cleanup expired blacklist entries (can be called periodically)
export async function cleanupExpiredTokens() {
const now = Math.floor(Date.now() / 1000);
await officerdb.delete(TokenBlacklist).where(lt(TokenBlacklist.expiresAt, now));
}
import { blacklistToken, cleanupExpiredTokens } from 'officerdb';
export const signoutHandler: Handler = async (ctx) => {
const user = ctx.get('user') as { jti: string; exp: number };
// Add token to blacklist
await officerdb
.insert(TokenBlacklist)
.values({
jti: user.jti,
expiresAt: user.exp,
})
.onConflictDoNothing();
await blacklistToken(user.jti, user.exp);
// Opportunistic cleanup of expired tokens (non-blocking)
cleanupExpiredTokens().catch(() => {});
+5 -10
View File
@@ -1,7 +1,7 @@
import type { Handler } from 'hono';
import { officerdb, count, Users } from 'officerdb';
import { getUserCount, createUser } from 'officerdb';
import { sign } from '@@/jwt';
import { USER_ROLES, USER_STATUSES } from 'definitions';
import type { USER_ROLES, USER_STATUSES } from 'definitions';
import * as errors from '@@/custom-errors';
import { sendMail } from 'emailer';
@@ -13,19 +13,14 @@ export const signupHandler: Handler = async function (ctx) {
throw errors.BAD_REQUEST('Invalid email address');
}
const result = await officerdb.select({ count: count() }).from(Users);
const userCount = result[0]?.count ?? 0;
const userCount = getUserCount();
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
const newUser = {
const dbUser = await createUser({
email: body.email as string,
status: 'Unverified' as (typeof USER_STATUSES)[number],
role: 'Admin' as (typeof USER_ROLES)[number],
};
const insertedUsers = await officerdb.insert(Users).values(newUser).returning();
if (!insertedUsers || insertedUsers.length === 0) throw errors.INTERNAL_SERVER_ERROR('Failed to create user');
const dbUser = insertedUsers[0]!;
});
const verificationCode = await sign({ id: dbUser.id, email: dbUser.email }, '24h');
const url = `${origin}/auth/verify?verificationCode=${verificationCode}`;
+5 -10
View File
@@ -1,24 +1,19 @@
import type { Handler } from 'hono';
import type { User } from 'types';
import * as errors from '@@/custom-errors';
import { officerdb, eq, Users, Passkeys } from 'officerdb';
import { getUserById, getPasskeysByEmailAndOrigin } 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 = await officerdb.query.Users.findFirst({
where: eq(Users.id, user.id),
columns: { password: false },
with: {
passkeys: { where: eq(Passkeys.origin, origin || '') },
},
});
const dbUser = getUserById(user.id);
if (!dbUser) return errors.NOT_FOUND();
const { passkeys, ...userWithoutPasskeys } = dbUser;
const returnUser = { ...userWithoutPasskeys, passkeyCount: passkeys.length };
const passkeys = getPasskeysByEmailAndOrigin(dbUser.email, origin || '');
const { password, ...userWithoutPassword } = dbUser;
const returnUser = { ...userWithoutPassword, passkeyCount: passkeys.length };
return ctx.json(returnUser);
};
+2 -4
View File
@@ -1,6 +1,6 @@
import type { Handler } from 'hono';
import type { User } from 'types';
import { officerdb, eq, Users } from 'officerdb';
import { getUserById } from 'officerdb';
import { verify } from '@@/jwt';
import * as errors from '@@/custom-errors';
@@ -22,9 +22,7 @@ export const verifyTokenHandler: Handler = async function (ctx) {
if (!userInfo?.id) throw errors.BAD_REQUEST('Token is invalid or expired');
const user = await officerdb.query.Users.findFirst({
where: eq(Users.id, userInfo.id),
});
const user = getUserById(userInfo.id);
if (!user) throw errors.NOT_FOUND('User not found');
// Reset-password tokens skip the verification status check
+4 -12
View File
@@ -1,6 +1,6 @@
import type { Handler } from 'hono';
import type { User } from 'types';
import { officerdb, eq, Users } from 'officerdb';
import { getUserById, updateUser } from 'officerdb';
import { verify as verifyJwt, sign } from '@@/jwt';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
@@ -11,9 +11,7 @@ export const verifyHandler: Handler = async function (ctx) {
const userInfo = (await verifyJwt(verificationCode)) as User;
if (!userInfo) throw errors.BAD_REQUEST();
const user = await officerdb.query.Users.findFirst({
where: eq(Users.id, userInfo.id),
});
const user = getUserById(userInfo.id);
if (!user) throw errors.NOT_FOUND('User not found');
const updates: Record<string, unknown> = { status: 'Active' };
@@ -37,16 +35,10 @@ export const verifyHandler: Handler = async function (ctx) {
updates.password = await argon2.hash(password);
}
const [updatedUser] = await officerdb
.update(Users)
.set(updates)
.where(eq(Users.id, userInfo.id))
.returning({ username: Users.username });
await updateUser(userInfo.id, updates);
// Re-fetch user to get final values after update
const finalUser = await officerdb.query.Users.findFirst({
where: eq(Users.id, userInfo.id),
});
const finalUser = getUserById(userInfo.id);
if (!finalUser) throw errors.NOT_FOUND('User not found');
// Issue a token so the user is logged in immediately