This commit is contained in:
2026-02-16 19:34:35 +00:00
commit 9ab0940ca4
784 changed files with 41710 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
import { createRouter } from '../../create-router';
import {
originMiddleware,
originValidationMiddleware,
userMiddleware,
bodyParser,
signinRateLimiter,
signupRateLimiter,
forgotPasswordRateLimiter,
} from '../../_middlewares';
import { signinHandler } from './signin';
import { signoutHandler } from './signout';
import { signupHandler } from './signup';
import { verifyHandler } from './verify';
import { verifyTokenHandler } from './verify-token';
import { resendVerificationHandler } from './resend-verification';
import { changePasswordHandler } from './change-password';
import { forgotPasswordHandler } from './forgot-password';
import { resetPasswordHandler } from './reset-password';
import { usersMe } from './users-me';
import { passkeyRouter } from './passkey-router';
export const authRouter = createRouter();
authRouter.use(bodyParser());
authRouter.use(originMiddleware);
authRouter.use(originValidationMiddleware);
authRouter.use('/', async (ctx) => ctx.json({ officerAuthServer: 'ok' }));
authRouter.get('/me', userMiddleware, usersMe);
authRouter.post('/signin', signinRateLimiter, signinHandler);
authRouter.post('/signout', userMiddleware, signoutHandler);
authRouter.post('/signup', signupRateLimiter, signupHandler);
authRouter.post('/verify', verifyHandler);
authRouter.post('/verify-token', verifyTokenHandler);
authRouter.post('/resend-verification', resendVerificationHandler);
authRouter.post('/change-password', userMiddleware, changePasswordHandler);
authRouter.post('/forgot-password', forgotPasswordRateLimiter, forgotPasswordHandler);
authRouter.post('/reset-password', resetPasswordHandler);
authRouter.route('/passkeys', passkeyRouter);
+37
View File
@@ -0,0 +1,37 @@
import type { Handler } from 'hono';
import { officerdb, eq, Users } from 'officerdb';
import argon2 from 'argon2';
import { sign } from '@@/jwt';
import * as errors from '@@/custom-errors';
import { validatePassword } from './validate-password';
const { PUBLIC_BUILD_ENV } = process.env;
const isProduction = PUBLIC_BUILD_ENV === 'production' || PUBLIC_BUILD_ENV === 'staging';
export const changePasswordHandler: Handler = async function (ctx) {
const { password, newPassword } = ctx.get('body');
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 },
});
if (!dbUser) throw errors.UNAUTHORIZED();
const isCorrectOldPassword = await argon2.verify(dbUser.password!, password);
if (!isCorrectOldPassword) {
throw errors.createError(403, 'Incorrect old password');
}
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));
const { id, email, name, role } = reqUser;
const token = await sign({ id, email, name, role });
return ctx.json({ token });
};
+27
View File
@@ -0,0 +1,27 @@
import type { Handler } from 'hono';
import { officerdb, eq, Users } from 'officerdb';
import { sign } from '@@/jwt';
import { sendMail } from 'emailer';
const { PUBLIC_URL } = process.env;
export const forgotPasswordHandler: Handler = async function (ctx) {
const { email } = ctx.get('body');
const dbUser = await officerdb.query.Users.findFirst({
where: eq(Users.email, email),
});
if (!dbUser) return ctx.json({ ok: true });
const verificationCode = await sign({ id: dbUser.id, email }, '6h');
const url = `${PUBLIC_URL}/auth/reset-password?verificationCode=${verificationCode}`;
await sendMail({
template: 'ForgotPassword',
subject: 'Reset your Officer password',
to: dbUser.email,
data: { email: dbUser.email, url },
});
return ctx.json({ ok: true });
};
+1
View File
@@ -0,0 +1 @@
export * from './auth';
+224
View File
@@ -0,0 +1,224 @@
import type { Handler } from 'hono';
import type { User } from 'types';
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 {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type { RegistrationResponseJSON, AuthenticationResponseJSON } from '@simplewebauthn/server';
export const passkeyRouter = createRouter();
const CHALLENGE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const RP_NAME = 'Officer';
function getRpId(origin: string): string {
try {
const url = new URL(origin);
return url.hostname;
} catch {
return 'officer.dev';
}
}
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();
const origin = ctx.get('origin') as string;
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 options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: rpId,
userName: email!,
userDisplayName: email!,
attestationType: 'none',
excludeCredentials: existingPasskeys.map((p) => ({
id: p.credentialId!,
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
await storeChallenge(email!, origin, options.challenge);
return ctx.json(options);
};
passkeyRouter.post('/challenge/:email', passkeyRateLimiter, passkeyRouterPostChallenge);
// Verify and store new passkey registration
const passkeyRouterPost: Handler = async (ctx) => {
const origin = ctx.get('origin') as string;
const rpId = getRpId(origin);
const { email } = ctx.get('user') as User;
const response = ctx.get('body') as RegistrationResponseJSON;
const storedChallenge = await getAndDeleteChallenge(email, origin);
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: storedChallenge,
expectedOrigin: origin,
expectedRPID: rpId,
});
if (!verification.verified || !verification.registrationInfo) {
throw errors.BAD_CREDENTIALS();
}
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
const values = {
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);
// Generate authentication options for signing in with passkey
const passkeyRouterGet: Handler = async (ctx) => {
const { email } = ctx.req.param();
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 options = await generateAuthenticationOptions({
rpID: rpId,
allowCredentials: passkeys.map((p) => ({
id: p.credentialId!,
})),
userVerification: 'preferred',
});
await storeChallenge(email!, origin, options.challenge);
return ctx.json(options);
};
passkeyRouter.get('/signin/:email', passkeyRateLimiter, passkeyRouterGet);
// Verify passkey authentication and issue token
const passkeyRouterPostVerify: Handler = async (ctx) => {
const { email } = ctx.req.param();
const origin = ctx.get('origin') as string;
const rpId = getRpId(origin);
const response = ctx.get('body') as AuthenticationResponseJSON;
const storedChallenge = await getAndDeleteChallenge(email!, origin);
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)),
});
if (!dbPasskey || !dbPasskey.publicKey) throw errors.BAD_CREDENTIALS();
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: storedChallenge,
expectedOrigin: origin,
expectedRPID: rpId,
credential: {
id: dbPasskey.credentialId!,
publicKey: Buffer.from(dbPasskey.publicKey, 'base64'),
counter: dbPasskey.counter,
},
});
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 },
});
if (!dbUser) throw errors.UNAUTHORIZED();
const { id, name, role } = dbUser;
const passkeys = dbUser.passkeys?.length ?? 0;
const token = await sign({
id,
email,
name,
role,
passkeys,
});
return ctx.json({
token,
user: {
id,
email,
name,
role,
passkeys,
},
});
};
passkeyRouter.post('/verify/:email', passkeyRateLimiter, passkeyRouterPostVerify);
@@ -0,0 +1,30 @@
import type { Handler } from 'hono';
import { officerdb, eq, Users } from 'officerdb';
import { sign } from '@@/jwt';
import * as errors from '@@/custom-errors';
import { sendMail } from 'emailer';
export const resendVerificationHandler: Handler = async function (ctx) {
const { email } = ctx.get('body');
const origin = ctx.get('origin');
if (!email || typeof email !== 'string') throw errors.BAD_REQUEST('Email is required');
const user = await officerdb.query.Users.findFirst({
where: eq(Users.email, email),
});
if (!user) throw errors.NOT_FOUND('User not found');
if (user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified');
const verificationCode = await sign({ id: user.id, email: user.email }, '24h');
const url = `${origin}/auth/verify?verificationCode=${verificationCode}`;
await sendMail({
template: 'VerifyAdmin',
subject: 'Verify your Officer account',
to: user.email,
data: { name: user.email, url },
});
return ctx.json({ ok: true });
};
+22
View File
@@ -0,0 +1,22 @@
import type { User } from 'types';
import type { Handler } from 'hono';
import { officerdb, eq, Users } from 'officerdb';
import { verify } from '@@/jwt';
import argon2 from 'argon2';
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 officerdb
.update(Users)
.set({ password: passwordHash, status: 'Active', passwordChangedAt: now })
.where(eq(Users.id, userInfo.id));
return ctx.json({ ok: true });
};
+46
View File
@@ -0,0 +1,46 @@
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 { sign } from '@@/jwt';
import { getClaudeDir } from '@@/data-path';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
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 passkeys = await officerdb.query.Passkeys.findMany({
where: and(eq(Passkeys.email, email), eq(Passkeys.origin, origin)),
});
if (!dbUser || !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));
if (!isValidPassword) throw errors.UNAUTHORIZED();
const { id, name, role } = dbUser;
mkdir(join(getClaudeDir(email), 'archived'), { recursive: true }).catch(() => {});
const tokenUser = { id, email, name, role, passkeys: passkeys.length };
if (passkeys.length > 0 && !origin.startsWith('chrome-extension://') && !TEST_USERS.includes(dbUser.id)) {
return ctx.json({ user: tokenUser });
}
const token = await sign(tokenUser);
if (origin.startsWith('chrome-extension://')) {
// console.log('token', token);
}
return ctx.json({ token, user: tokenUser });
};
+26
View File
@@ -0,0 +1,26 @@
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));
}
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();
// Opportunistic cleanup of expired tokens (non-blocking)
cleanupExpiredTokens().catch(() => {});
return ctx.json({ ok: true });
};
+41
View File
@@ -0,0 +1,41 @@
import type { Handler } from 'hono';
import { officerdb, count, Users } from 'officerdb';
import { sign } from '@@/jwt';
import { USER_ROLES, USER_STATUSES } from 'definitions';
import * as errors from '@@/custom-errors';
import { sendMail } from 'emailer';
export const signupHandler: Handler = async function (ctx) {
const body = ctx.get('body');
const origin = ctx.get('origin');
if (!body.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
throw errors.BAD_REQUEST('Invalid email address');
}
const result = await officerdb.select({ count: count() }).from(Users);
const userCount = result[0]?.count ?? 0;
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
const newUser = {
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}`;
await sendMail({
template: 'VerifyAdmin',
subject: 'Verify your Officer account',
to: dbUser.email,
data: { name: dbUser.email, url },
});
return ctx.json({ ok: true, user: { id: dbUser.id, email: dbUser.email } });
};
+24
View File
@@ -0,0 +1,24 @@
import type { Handler } from 'hono';
import type { User } from 'types';
import * as errors from '@@/custom-errors';
import { officerdb, eq, Users, Passkeys } 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 || '') },
},
});
if (!dbUser) return errors.NOT_FOUND();
const { passkeys, ...userWithoutPasskeys } = dbUser;
const returnUser = { ...userWithoutPasskeys, passkeyCount: passkeys.length };
return ctx.json(returnUser);
};
+23
View File
@@ -0,0 +1,23 @@
import * as errors from '@@/custom-errors';
export function validatePassword(password: string | undefined): void {
if (!password || password.length < 12) {
throw errors.BAD_REQUEST('Password must be at least 12 characters');
}
if (!/[a-z]/.test(password)) {
throw errors.BAD_REQUEST('Password must contain lowercase letters');
}
if (!/[A-Z]/.test(password)) {
throw errors.BAD_REQUEST('Password must contain uppercase letters');
}
if (!/[0-9]/.test(password)) {
throw errors.BAD_REQUEST('Password must contain numbers');
}
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
throw errors.BAD_REQUEST('Password must contain special characters (!@#$%^&* etc.)');
}
}
+27
View File
@@ -0,0 +1,27 @@
import type { Handler } from 'hono';
import type { User } from 'types';
import { officerdb, eq, Users } from 'officerdb';
import { verify } from '@@/jwt';
import * as errors from '@@/custom-errors';
export const verifyTokenHandler: Handler = async function (ctx) {
const { verificationCode } = ctx.get('body');
if (!verificationCode) throw errors.BAD_REQUEST('Missing verification code');
let userInfo: User;
try {
userInfo = (await verify(verificationCode)) as User;
} catch {
throw errors.BAD_REQUEST('Token is invalid or expired');
}
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),
});
if (!user) throw errors.NOT_FOUND('User not found');
if (user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified');
return ctx.json({ ok: true, email: user.email });
};
+42
View File
@@ -0,0 +1,42 @@
import type { Handler } from 'hono';
import type { User } from 'types';
import { officerdb, eq, Users } from 'officerdb';
import { verify as verifyJwt, sign } from '@@/jwt';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
import { validatePassword } from './validate-password';
export const verifyHandler: Handler = async function (ctx) {
const { verificationCode, name, password, confirmPassword } = ctx.get('body');
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),
});
if (!user) throw errors.NOT_FOUND('User not found');
const updates: Record<string, unknown> = { status: 'Active' };
if (name) {
if (typeof name !== 'string' || !name.trim() || name.length > 128) {
throw errors.BAD_REQUEST('Name must be between 1 and 128 characters');
}
updates.name = name.trim();
}
if (password) {
validatePassword(password);
if (password !== confirmPassword) {
throw errors.BAD_REQUEST('Passwords do not match');
}
updates.password = await argon2.hash(password);
}
await officerdb.update(Users).set(updates).where(eq(Users.id, userInfo.id));
// Issue a token so the user is logged in immediately
const token = await sign({ id: userInfo.id, email: userInfo.email });
return ctx.json({ ok: true, token });
};