auth: sessions, login/logout/me/change-password, CSRF, guards, audit
argon2id passwords with a min length and a common-password list (SecLists, MIT). Login failures are limited per IP and per email; must_change_password blocks everything outside /api/auth/*. The first admin is created from env on startup. pnpm dev derives DATABASE_URL from .env. Task: M1-T3
This commit is contained in:
1 parent
15b8a20751
commit
13733ac262
20 files changed
+1937
-46
No files matched your search
@@ -11,7 +11,9 @@
|
||||
"db:generate": "drizzle-kit generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.3",
|
||||
"@fastify/static": "^10.1.6",
|
||||
"@node-rs/argon2": "^2.2.2",
|
||||
"@selfphoneng/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.45.4",
|
||||
"fastify": "^5.12.7",
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildApp } from './app.ts';
|
||||
import { ConfigError, loadConfig } from './config.ts';
|
||||
|
||||
const env = { APP_SECRET: Buffer.alloc(32, 7).toString('base64'), PUBLIC_HOSTNAME: 'pbx.test' };
|
||||
|
||||
describe('config', () => {
|
||||
it('parses a minimal env with defaults', () => {
|
||||
expect(loadConfig(env)).toMatchObject({ API_PORT: 8080, TRUST_PROXY: true });
|
||||
});
|
||||
it('rejects a missing or short APP_SECRET', () => {
|
||||
expect(() => loadConfig({ PUBLIC_HOSTNAME: 'x' })).toThrow(ConfigError);
|
||||
expect(() => loadConfig({ ...env, APP_SECRET: 'c2hvcnQ=' })).toThrow(/APP_SECRET/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('app', () => {
|
||||
const pub = mkdtempSync(join(tmpdir(), 'sp-public-'));
|
||||
writeFileSync(join(pub, 'index.html'), '<h1>spa</h1>');
|
||||
const app = buildApp({ ...loadConfig(env), LOG_LEVEL: 'fatal' }, pub);
|
||||
|
||||
it('GET /healthz', async () => {
|
||||
const r = await app.inject('/healthz');
|
||||
expect(r.json()).toEqual({ ok: true });
|
||||
});
|
||||
it('falls back to index.html for SPA routes only', async () => {
|
||||
expect((await app.inject('/admin/users')).body).toBe('<h1>spa</h1>');
|
||||
expect((await app.inject('/api/nope')).statusCode).toBe(404);
|
||||
expect((await app.inject('/prov/x.cfg')).statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
+46
-8
@@ -2,13 +2,25 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import Fastify from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { Config } from './config.ts';
|
||||
import type { Db } from './db/client.ts';
|
||||
import { errorHandler, forbidden, notFound, sendError } from './errors.ts';
|
||||
import { authRoutes } from './routes/auth.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
db: Db;
|
||||
}
|
||||
}
|
||||
|
||||
// Paths the SPA must never swallow (04 §1 step 5).
|
||||
const NON_SPA = /^\/(api|prov|hooks)(\/|$)/;
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
export function buildApp(config: Config, publicDir = resolve('public')) {
|
||||
export function buildApp(config: Config, db: Db, publicDir = resolve('public')) {
|
||||
const app = Fastify({
|
||||
trustProxy: config.TRUST_PROXY ? '127.0.0.1' : false,
|
||||
logger: {
|
||||
@@ -25,16 +37,42 @@ export function buildApp(config: Config, publicDir = resolve('public')) {
|
||||
},
|
||||
});
|
||||
|
||||
app.get('/healthz', async () => ({ ok: true }));
|
||||
app.decorate('db', db);
|
||||
app.decorateRequest('user', null);
|
||||
app.decorateRequest('sessionId', null);
|
||||
app.register(fastifyCookie);
|
||||
app.setErrorHandler(errorHandler);
|
||||
|
||||
// CSRF (04 §2.2): browsers can't add this header cross-site without CORS, and CORS is off.
|
||||
// ponytail: required on every unsafe /api request, not only cookie-authenticated ones; the
|
||||
// Bearer-token exemption arrives with API tokens in M11.
|
||||
app.addHook('onRequest', async (req, reply) => {
|
||||
if (
|
||||
req.url.startsWith('/api/') &&
|
||||
!SAFE_METHODS.has(req.method) &&
|
||||
req.headers['x-requested-with'] !== 'selfphoneng'
|
||||
)
|
||||
return sendError(reply, forbidden('Missing X-Requested-With header', 'csrf'));
|
||||
});
|
||||
|
||||
app.get('/healthz', async (_req, reply) => {
|
||||
try {
|
||||
await db.execute(sql`select 1`);
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return reply.code(503).send({ ok: false, reasons: ['db'] });
|
||||
}
|
||||
});
|
||||
|
||||
app.register(authRoutes);
|
||||
|
||||
if (existsSync(publicDir)) {
|
||||
app.register(fastifyStatic, { root: publicDir, wildcard: false });
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
if (req.method === 'GET' && !NON_SPA.test(req.url)) return reply.sendFile('index.html');
|
||||
return reply
|
||||
.code(404)
|
||||
.send({ error: { code: 'not_found', message: 'Not found', details: {} } });
|
||||
});
|
||||
}
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
if (existsSync(publicDir) && req.method === 'GET' && !NON_SPA.test(req.url))
|
||||
return reply.sendFile('index.html');
|
||||
return sendError(reply, notFound());
|
||||
});
|
||||
return app;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// preHandler guards (04 §2.4). Routes list them in `preHandler`.
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type { users } from '../db/schema.ts';
|
||||
import { forbidden, unauthorized } from '../errors.ts';
|
||||
import { COOKIE, getSessionUser, setSessionCookie } from './sessions.ts';
|
||||
|
||||
export type CurrentUser = typeof users.$inferSelect;
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
user: CurrentUser | null;
|
||||
sessionId: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets req.user from the session cookie. Enforces must_change_password outside /api/auth/*. */
|
||||
export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const token = req.cookies[COOKIE];
|
||||
const found = token ? await getSessionUser(req.server.db, token) : null;
|
||||
if (!found) throw unauthorized();
|
||||
if (found.extended) setSessionCookie(reply, token!);
|
||||
req.user = found.user;
|
||||
req.sessionId = found.sessionId;
|
||||
if (found.user.mustChangePassword && !req.url.startsWith('/api/auth/'))
|
||||
throw forbidden('Change your password first', 'password_change_required');
|
||||
}
|
||||
|
||||
export async function requireAdmin(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
await requireAuth(req, reply);
|
||||
if (req.user!.role !== 'admin') throw forbidden('Admins only');
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Login failure limits (04 §2.2): 5 per IP per 15 min, 10 per email per hour.
|
||||
// ponytail: in-process memory, resets on restart; fine for one api process (v1 has no HA).
|
||||
const MIN = 60_000;
|
||||
const RULES = { ip: { max: 5, windowMs: 15 * MIN }, email: { max: 10, windowMs: 60 * MIN } };
|
||||
|
||||
const failures = new Map<string, number[]>();
|
||||
|
||||
function recent(key: string, windowMs: number, now: number): number[] {
|
||||
const list = (failures.get(key) ?? []).filter((t) => now - t < windowMs);
|
||||
if (list.length) failures.set(key, list);
|
||||
else failures.delete(key);
|
||||
return list;
|
||||
}
|
||||
|
||||
export function isLoginBlocked(ip: string, email: string, now = Date.now()): boolean {
|
||||
return (
|
||||
recent(`ip:${ip}`, RULES.ip.windowMs, now).length >= RULES.ip.max ||
|
||||
recent(`email:${email.toLowerCase()}`, RULES.email.windowMs, now).length >= RULES.email.max
|
||||
);
|
||||
}
|
||||
|
||||
export function recordLoginFailure(ip: string, email: string, now = Date.now()): void {
|
||||
for (const key of [`ip:${ip}`, `email:${email.toLowerCase()}`]) {
|
||||
failures.set(key, [...(failures.get(key) ?? []), now]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Tests only. */
|
||||
export function resetLoginLimiter(): void {
|
||||
failures.clear();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// argon2id with library defaults; minimum 10 chars; reject common passwords (04 §2.1).
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
import { PASSWORD_MIN } from '@selfphoneng/shared';
|
||||
import { badRequest } from '../errors.ts';
|
||||
|
||||
const COMMON = new Set(
|
||||
readFileSync(new URL('./common-passwords.txt', import.meta.url), 'utf8')
|
||||
.split('\n')
|
||||
.filter((l) => l && !l.startsWith('#'))
|
||||
.map((l) => l.toLowerCase()),
|
||||
);
|
||||
|
||||
export const hashPassword = (plain: string) => hash(plain);
|
||||
|
||||
export async function verifyPassword(hashed: string, plain: string): Promise<boolean> {
|
||||
try {
|
||||
return await verify(hashed, plain);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// A real hash to verify against when the email is unknown, so timing doesn't reveal which emails exist.
|
||||
const DUMMY = hash('dummy-password-for-timing');
|
||||
export async function burnVerify(plain: string): Promise<void> {
|
||||
await verifyPassword(await DUMMY, plain);
|
||||
}
|
||||
|
||||
export function assertPasswordPolicy(plain: string): void {
|
||||
if (plain.length < PASSWORD_MIN)
|
||||
throw badRequest(`Password must be at least ${PASSWORD_MIN} characters`);
|
||||
if (COMMON.has(plain.toLowerCase())) throw badRequest('This password is too common');
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Browser sessions (04 §2.2): random token in the cookie, sha256(token) in sessions.id.
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { and, eq, gt, ne } from 'drizzle-orm';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { sessions, users } from '../db/schema.ts';
|
||||
|
||||
export const COOKIE = 'sp_session';
|
||||
const DAY = 86_400_000;
|
||||
const LIFETIME = 14 * DAY;
|
||||
const EXTEND_BELOW = 7 * DAY;
|
||||
|
||||
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||
|
||||
export function setSessionCookie(reply: FastifyReply, token: string) {
|
||||
reply.setCookie(COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: LIFETIME / 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSessionCookie(reply: FastifyReply) {
|
||||
reply.clearCookie(COOKIE, { path: '/' });
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
db: Db,
|
||||
userId: string,
|
||||
ip: string | null,
|
||||
userAgent: string | null,
|
||||
): Promise<string> {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
await db.insert(sessions).values({
|
||||
id: sha256(token),
|
||||
userId,
|
||||
expiresAt: new Date(Date.now() + LIFETIME),
|
||||
ip,
|
||||
userAgent: userAgent?.slice(0, 500) ?? null,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Resolves a cookie token to its active user, sliding the expiry when under 7 days are left. */
|
||||
export async function getSessionUser(db: Db, token: string) {
|
||||
const id = sha256(token);
|
||||
const [row] = await db
|
||||
.select({ expiresAt: sessions.expiresAt, user: users })
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date()), eq(users.isActive, true)));
|
||||
if (!row) return null;
|
||||
let extended = false;
|
||||
if (row.expiresAt.getTime() - Date.now() < EXTEND_BELOW) {
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ expiresAt: new Date(Date.now() + LIFETIME) })
|
||||
.where(eq(sessions.id, id));
|
||||
extended = true;
|
||||
}
|
||||
return { user: row.user, sessionId: id, extended };
|
||||
}
|
||||
|
||||
export async function deleteSession(db: Db, token: string) {
|
||||
await db.delete(sessions).where(eq(sessions.id, sha256(token)));
|
||||
}
|
||||
|
||||
/** Logs a user out everywhere except (optionally) the current session. */
|
||||
export async function deleteUserSessions(db: Db, userId: string, keepSessionId?: string) {
|
||||
await db
|
||||
.delete(sessions)
|
||||
.where(
|
||||
keepSessionId
|
||||
? and(eq(sessions.userId, userId), ne(sessions.id, keepSessionId))
|
||||
: eq(sessions.userId, userId),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ConfigError, loadConfig } from './config.ts';
|
||||
|
||||
const env = {
|
||||
APP_SECRET: Buffer.alloc(32, 7).toString('base64'),
|
||||
PUBLIC_HOSTNAME: 'pbx.test',
|
||||
DATABASE_URL: 'postgres://u:p@127.0.0.1:5433/db',
|
||||
INITIAL_ADMIN_EMAIL: 'admin@example.com',
|
||||
INITIAL_ADMIN_PASSWORD: 'x',
|
||||
};
|
||||
|
||||
describe('config', () => {
|
||||
it('parses a minimal env with defaults', () => {
|
||||
expect(loadConfig(env)).toMatchObject({ API_PORT: 8080, TRUST_PROXY: true });
|
||||
});
|
||||
it('rejects a missing or short APP_SECRET', () => {
|
||||
expect(() => loadConfig({ ...env, APP_SECRET: undefined })).toThrow(ConfigError);
|
||||
expect(() => loadConfig({ ...env, APP_SECRET: 'c2hvcnQ=' })).toThrow(/APP_SECRET/);
|
||||
});
|
||||
it('rejects a non-postgres DATABASE_URL', () => {
|
||||
expect(() => loadConfig({ ...env, DATABASE_URL: 'mysql://x' })).toThrow(/DATABASE_URL/);
|
||||
});
|
||||
});
|
||||
+10
-1
@@ -1,12 +1,15 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { z } from 'zod';
|
||||
|
||||
// ponytail: only the vars used so far; DB/AMI/admin vars join in M1/M2 with the code that reads them.
|
||||
// ponytail: only the vars read so far; AMI vars join in M2 with the code that reads them.
|
||||
const EnvSchema = z.object({
|
||||
APP_SECRET: z
|
||||
.string()
|
||||
.refine((v) => Buffer.from(v, 'base64').length === 32, 'must be 32 random bytes, base64'),
|
||||
PUBLIC_HOSTNAME: z.string().min(1),
|
||||
DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, 'must be a postgres:// URL'),
|
||||
INITIAL_ADMIN_EMAIL: z.email(),
|
||||
INITIAL_ADMIN_PASSWORD: z.string().min(1),
|
||||
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
TRUST_PROXY: z.stringbool().default(true),
|
||||
@@ -18,6 +21,12 @@ export type Config = z.infer<typeof EnvSchema>;
|
||||
export class ConfigError extends Error {}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
// `pnpm dev` reads .env, which has no DATABASE_URL (compose builds it); point at the dev container.
|
||||
if (!env.DATABASE_URL && env.POSTGRES_PASSWORD)
|
||||
env = {
|
||||
...env,
|
||||
DATABASE_URL: `postgres://selfphoneng:${env.POSTGRES_PASSWORD}@127.0.0.1:${env.POSTGRES_PORT ?? 5433}/selfphoneng`,
|
||||
};
|
||||
const r = EnvSchema.safeParse(env);
|
||||
if (r.success) return r.data;
|
||||
const lines = r.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// API error shape (04 §7.1): HTTP status + { error: { code, message, details } }.
|
||||
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
export class HttpError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly details: unknown;
|
||||
constructor(status: number, code: string, message: string, details: unknown = {}) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export const badRequest = (message: string, details?: unknown) =>
|
||||
new HttpError(400, 'validation_failed', message, details);
|
||||
export const unauthorized = (message = 'Not signed in') =>
|
||||
new HttpError(401, 'unauthorized', message);
|
||||
export const forbidden = (message = 'Not allowed', code = 'forbidden') =>
|
||||
new HttpError(403, code, message);
|
||||
export const notFound = (message = 'Not found') => new HttpError(404, 'not_found', message);
|
||||
export const conflict = (message: string, details?: unknown) =>
|
||||
new HttpError(409, 'conflict', message, details);
|
||||
export const rateLimited = (message = 'Too many attempts, try again later') =>
|
||||
new HttpError(429, 'rate_limited', message);
|
||||
|
||||
export function sendError(reply: FastifyReply, e: HttpError) {
|
||||
return reply
|
||||
.code(e.status)
|
||||
.send({ error: { code: e.code, message: e.message, details: e.details } });
|
||||
}
|
||||
|
||||
export function errorHandler(err: FastifyError, req: FastifyRequest, reply: FastifyReply) {
|
||||
if (err instanceof HttpError) return sendError(reply, err);
|
||||
if (err instanceof ZodError)
|
||||
return sendError(reply, badRequest('Invalid input', { issues: err.issues }));
|
||||
const status = err.statusCode ?? 500;
|
||||
if (status < 500) return sendError(reply, new HttpError(status, 'bad_request', err.message));
|
||||
req.log.error({ err }, 'unhandled error');
|
||||
return sendError(reply, new HttpError(500, 'internal', 'Internal error'));
|
||||
}
|
||||
+20
-2
@@ -1,6 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Startup order (04 §1).
|
||||
import { buildApp } from './app.ts';
|
||||
import { ConfigError, loadConfig } from './config.ts';
|
||||
import { setSecretKey } from './crypto/secrets.ts';
|
||||
import { createDb, runMigrations } from './db/client.ts';
|
||||
import { ensureInitialAdmin } from './services/bootstrap.ts';
|
||||
|
||||
let config;
|
||||
try {
|
||||
@@ -10,10 +14,24 @@ try {
|
||||
console.error(err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
setSecretKey(config.APP_SECRET);
|
||||
|
||||
const app = buildApp(config);
|
||||
const { db, pool } = createDb(config.DATABASE_URL);
|
||||
const app = buildApp(config, db); // creates the pino logger
|
||||
await runMigrations(db);
|
||||
app.log.info('database migrated');
|
||||
if (
|
||||
(await ensureInitialAdmin(db, config.INITIAL_ADMIN_EMAIL, config.INITIAL_ADMIN_PASSWORD)) ===
|
||||
'created'
|
||||
)
|
||||
app.log.info({ email: config.INITIAL_ADMIN_EMAIL }, 'initial admin created');
|
||||
await app.listen({ host: '127.0.0.1', port: config.API_PORT });
|
||||
|
||||
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
|
||||
process.once(sig, () => void app.close().then(() => process.exit(0)));
|
||||
process.once(sig, () => {
|
||||
void app
|
||||
.close()
|
||||
.then(() => pool.end())
|
||||
.then(() => process.exit(0));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/auth/* (04 §2, §7.3).
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { ChangePasswordSchema, LoginSchema, type AuthUser } from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { extensions, users } from '../db/schema.ts';
|
||||
import { unauthorized, rateLimited, badRequest } from '../errors.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { requireAuth, type CurrentUser } from '../auth/guards.ts';
|
||||
import { isLoginBlocked, recordLoginFailure } from '../auth/login-limiter.ts';
|
||||
import {
|
||||
assertPasswordPolicy,
|
||||
burnVerify,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
} from '../auth/passwords.ts';
|
||||
import {
|
||||
COOKIE,
|
||||
clearSessionCookie,
|
||||
createSession,
|
||||
deleteSession,
|
||||
deleteUserSessions,
|
||||
setSessionCookie,
|
||||
} from '../auth/sessions.ts';
|
||||
|
||||
export async function toAuthUser(db: Db, u: CurrentUser): Promise<AuthUser> {
|
||||
const [ext] = await db
|
||||
.select({ id: extensions.id, number: extensions.number, name: extensions.name })
|
||||
.from(extensions)
|
||||
.where(eq(extensions.userId, u.id));
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
displayName: u.displayName,
|
||||
role: u.role,
|
||||
mustChangePassword: u.mustChangePassword,
|
||||
extension: ext ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
|
||||
app.post('/api/auth/login', async (req, reply) => {
|
||||
const body = LoginSchema.parse(req.body);
|
||||
if (isLoginBlocked(req.ip, body.email)) throw rateLimited();
|
||||
const [user] = await db.select().from(users).where(eq(users.email, body.email));
|
||||
const ok = user?.isActive
|
||||
? await verifyPassword(user.passwordHash, body.password)
|
||||
: (await burnVerify(body.password), false);
|
||||
if (!user || !ok) {
|
||||
recordLoginFailure(req.ip, body.email);
|
||||
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed');
|
||||
await audit(db, {
|
||||
actorUserId: user?.id,
|
||||
actorIp: req.ip,
|
||||
action: 'login_failed',
|
||||
entityType: 'user',
|
||||
entityId: user?.id,
|
||||
summary: `Failed login for ${body.email}`,
|
||||
});
|
||||
throw unauthorized('Wrong email or password');
|
||||
}
|
||||
const token = await createSession(db, user.id, req.ip, req.headers['user-agent'] ?? null);
|
||||
setSessionCookie(reply, token);
|
||||
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id));
|
||||
await audit(db, {
|
||||
actorUserId: user.id,
|
||||
actorIp: req.ip,
|
||||
action: 'login',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
summary: `${user.email} logged in`,
|
||||
});
|
||||
return { user: await toAuthUser(db, user) };
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', { preHandler: requireAuth }, async (req, reply) => {
|
||||
await deleteSession(db, req.cookies[COOKIE]!);
|
||||
clearSessionCookie(reply);
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'logout',
|
||||
entityType: 'user',
|
||||
entityId: req.user!.id,
|
||||
summary: `${req.user!.email} logged out`,
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', { preHandler: requireAuth }, async (req) => ({
|
||||
user: await toAuthUser(db, req.user!),
|
||||
}));
|
||||
|
||||
app.post('/api/auth/change-password', { preHandler: requireAuth }, async (req) => {
|
||||
const body = ChangePasswordSchema.parse(req.body);
|
||||
const user = req.user!;
|
||||
if (!(await verifyPassword(user.passwordHash, body.currentPassword)))
|
||||
throw badRequest('Current password is wrong');
|
||||
if (body.newPassword === body.currentPassword)
|
||||
throw badRequest('Choose a password different from the current one');
|
||||
assertPasswordPolicy(body.newPassword);
|
||||
await db
|
||||
.update(users)
|
||||
.set({ passwordHash: await hashPassword(body.newPassword), mustChangePassword: false })
|
||||
.where(eq(users.id, user.id));
|
||||
await deleteUserSessions(db, user.id, req.sessionId!);
|
||||
await audit(db, {
|
||||
actorUserId: user.id,
|
||||
actorIp: req.ip,
|
||||
action: 'password_changed',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
summary: `${user.email} changed their password`,
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { auditLog } from '../db/schema.ts';
|
||||
|
||||
const SECRET_KEY = /pass|secret|token|pin|enc$/i;
|
||||
|
||||
/** Deep copy with every secret-looking key replaced by "********" (02 audit_log.diff). */
|
||||
export function redact(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(redact);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([k, v]) => [k, SECRET_KEY.test(k) ? '********' : redact(v)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export type AuditEntry = {
|
||||
actorUserId?: string | null;
|
||||
actorIp?: string | null;
|
||||
action: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
summary: string;
|
||||
diff?: unknown;
|
||||
};
|
||||
|
||||
export async function audit(db: Db, e: AuditEntry): Promise<void> {
|
||||
await db.insert(auditLog).values({
|
||||
actorUserId: e.actorUserId ?? null,
|
||||
actorIp: e.actorIp ?? null,
|
||||
action: e.action,
|
||||
entityType: e.entityType ?? null,
|
||||
entityId: e.entityId ?? null,
|
||||
summary: e.summary,
|
||||
diff: e.diff === undefined ? null : redact(e.diff),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Startup step 4 (04 §1): create the first admin from env if no admin exists.
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { users } from '../db/schema.ts';
|
||||
import { hashPassword } from '../auth/passwords.ts';
|
||||
import { audit } from './audit.ts';
|
||||
|
||||
export async function ensureInitialAdmin(
|
||||
db: Db,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<'created' | 'exists'> {
|
||||
const [admin] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.role, 'admin'))
|
||||
.limit(1);
|
||||
if (admin) return 'exists';
|
||||
// The password comes from .env and must be changed at first login, so the policy is not applied here.
|
||||
const [created] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email,
|
||||
displayName: 'Administrator',
|
||||
passwordHash: await hashPassword(password),
|
||||
role: 'admin',
|
||||
mustChangePassword: true,
|
||||
})
|
||||
.returning({ id: users.id });
|
||||
await audit(db, {
|
||||
action: 'create',
|
||||
entityType: 'user',
|
||||
entityId: created!.id,
|
||||
summary: `Initial admin ${email} created from INITIAL_ADMIN_EMAIL`,
|
||||
});
|
||||
return 'created';
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// A full api on a throwaway database, for Fastify inject() tests.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { buildApp } from '../src/app.ts';
|
||||
import { loadConfig } from '../src/config.ts';
|
||||
import { setSecretKey } from '../src/crypto/secrets.ts';
|
||||
import { users } from '../src/db/schema.ts';
|
||||
import { hashPassword } from '../src/auth/passwords.ts';
|
||||
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
|
||||
import { ensureInitialAdmin } from '../src/services/bootstrap.ts';
|
||||
import { createTestDb } from './db.ts';
|
||||
|
||||
export const ADMIN = { email: 'admin@example.com', password: 'initial-admin-pw' };
|
||||
export const H = { 'x-requested-with': 'selfphoneng' };
|
||||
|
||||
export async function createTestApp(extend?: (app: ReturnType<typeof buildApp>) => void) {
|
||||
const t = await createTestDb();
|
||||
const config = loadConfig({
|
||||
APP_SECRET: randomBytes(32).toString('base64'),
|
||||
PUBLIC_HOSTNAME: 'pbx.test',
|
||||
DATABASE_URL: t.url,
|
||||
INITIAL_ADMIN_EMAIL: ADMIN.email,
|
||||
INITIAL_ADMIN_PASSWORD: ADMIN.password,
|
||||
LOG_LEVEL: 'fatal',
|
||||
});
|
||||
setSecretKey(config.APP_SECRET);
|
||||
const pub = mkdtempSync(join(tmpdir(), 'sp-public-'));
|
||||
writeFileSync(join(pub, 'index.html'), '<h1>spa</h1>');
|
||||
const app = buildApp(config, t.db, pub);
|
||||
extend?.(app);
|
||||
await ensureInitialAdmin(t.db, ADMIN.email, ADMIN.password);
|
||||
resetLoginLimiter();
|
||||
await app.ready();
|
||||
|
||||
/** Logs in and returns the Cookie header value. */
|
||||
async function login(email: string, password: string): Promise<string> {
|
||||
const r = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
headers: H,
|
||||
payload: { email, password },
|
||||
});
|
||||
if (r.statusCode !== 200) throw new Error(`login failed: ${r.statusCode} ${r.body}`);
|
||||
const c = r.cookies.find((x) => x.name === 'sp_session')!;
|
||||
return `sp_session=${c.value}`;
|
||||
}
|
||||
|
||||
/** Creates a user directly in the DB (no password-change requirement). */
|
||||
async function addUser(email: string, password: string, role: 'admin' | 'user' = 'user') {
|
||||
const [u] = await t.db
|
||||
.insert(users)
|
||||
.values({ email, displayName: email, passwordHash: await hashPassword(password), role })
|
||||
.returning();
|
||||
return u!;
|
||||
}
|
||||
|
||||
async function clearMustChange(email: string) {
|
||||
await t.db.update(users).set({ mustChangePassword: false }).where(eq(users.email, email));
|
||||
}
|
||||
|
||||
return {
|
||||
app,
|
||||
db: t.db,
|
||||
login,
|
||||
addUser,
|
||||
clearMustChange,
|
||||
close: async () => {
|
||||
await app.close();
|
||||
await t.drop();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M1-T3 acceptance (09-roadmap) plus the auth rules in 04 §2.
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { auditLog, users } from '../src/db/schema.ts';
|
||||
import { requireAdmin } from '../src/auth/guards.ts';
|
||||
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe.skipIf(!hasTestDb)('auth', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let adminPassword = ADMIN.password;
|
||||
beforeAll(async () => {
|
||||
// A stand-in admin route until the real admin routes exist.
|
||||
t = await createTestApp((app) =>
|
||||
app.get('/api/admin-probe', { preHandler: requireAdmin }, async () => ({ ok: true })),
|
||||
);
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
beforeEach(() => resetLoginLimiter());
|
||||
|
||||
const login = (email: string, password: string, headers: Record<string, string> = H) =>
|
||||
t.app.inject({ method: 'POST', url: '/api/auth/login', headers, payload: { email, password } });
|
||||
const cookieOf = (r: Awaited<ReturnType<typeof login>>) =>
|
||||
`sp_session=${r.cookies.find((c) => c.name === 'sp_session')!.value}`;
|
||||
const get = (url: string, cookie?: string) =>
|
||||
t.app.inject({ url, headers: cookie ? { cookie } : {} });
|
||||
|
||||
it('good login sets an HttpOnly, Secure, SameSite=Lax session cookie', async () => {
|
||||
const r = await login(ADMIN.email, adminPassword);
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.cookies.find((x) => x.name === 'sp_session')).toMatchObject({
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
path: '/',
|
||||
maxAge: 1209600,
|
||||
});
|
||||
expect(r.json().user).toMatchObject({
|
||||
email: ADMIN.email,
|
||||
role: 'admin',
|
||||
mustChangePassword: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('emails match case-insensitively', async () => {
|
||||
expect((await login(ADMIN.email.toUpperCase(), adminPassword)).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('bad password → 401 and an audited login_failed with the IP', async () => {
|
||||
const r = await login(ADMIN.email, 'wrong-password');
|
||||
expect(r.statusCode).toBe(401);
|
||||
expect(r.json().error.code).toBe('unauthorized');
|
||||
const [row] = await t.db.select().from(auditLog).orderBy(desc(auditLog.id)).limit(1);
|
||||
expect(row).toMatchObject({ action: 'login_failed', actorIp: '127.0.0.1' });
|
||||
expect(JSON.stringify(row)).not.toContain('wrong-password');
|
||||
});
|
||||
|
||||
it('unknown email → the same 401', async () => {
|
||||
expect((await login('nobody@example.com', 'whatever-pw')).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('6th failure from one IP → 429, even with the right password', async () => {
|
||||
for (let i = 0; i < 5; i++) expect((await login(ADMIN.email, `bad-${i}`)).statusCode).toBe(401);
|
||||
expect((await login(ADMIN.email, 'bad-6')).statusCode).toBe(429);
|
||||
expect((await login(ADMIN.email, adminPassword)).statusCode).toBe(429);
|
||||
});
|
||||
|
||||
it('POST without X-Requested-With → 403', async () => {
|
||||
const r = await login(ADMIN.email, adminPassword, {});
|
||||
expect(r.statusCode).toBe(403);
|
||||
expect(r.json().error.code).toBe('csrf');
|
||||
});
|
||||
|
||||
it('must_change_password blocks everything outside /api/auth/* until changed', async () => {
|
||||
const cookie = cookieOf(await login(ADMIN.email, adminPassword));
|
||||
const blocked = await get('/api/admin-probe', cookie);
|
||||
expect(blocked.statusCode).toBe(403);
|
||||
expect(blocked.json().error.code).toBe('password_change_required');
|
||||
expect((await get('/api/auth/me', cookie)).statusCode).toBe(200);
|
||||
|
||||
const change = (newPassword: string) =>
|
||||
t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/change-password',
|
||||
headers: { ...H, cookie },
|
||||
payload: { currentPassword: adminPassword, newPassword },
|
||||
});
|
||||
expect((await change('short')).statusCode).toBe(400);
|
||||
expect((await change('qwertyuiop')).json().error.message).toMatch(/too common/);
|
||||
expect((await change('a-much-better-passphrase')).statusCode).toBe(200);
|
||||
adminPassword = 'a-much-better-passphrase';
|
||||
expect((await get('/api/admin-probe', cookie)).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('changing the password logs out other sessions', async () => {
|
||||
const a = cookieOf(await login(ADMIN.email, adminPassword));
|
||||
const b = cookieOf(await login(ADMIN.email, adminPassword));
|
||||
const r = await t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/change-password',
|
||||
headers: { ...H, cookie: a },
|
||||
payload: { currentPassword: adminPassword, newPassword: 'another-good-passphrase' },
|
||||
});
|
||||
expect(r.statusCode).toBe(200);
|
||||
adminPassword = 'another-good-passphrase';
|
||||
expect((await get('/api/auth/me', a)).statusCode).toBe(200);
|
||||
expect((await get('/api/auth/me', b)).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('non-admin hitting an admin route → 403', async () => {
|
||||
await t.addUser('user@example.com', 'user-password-123');
|
||||
const cookie = cookieOf(await login('user@example.com', 'user-password-123'));
|
||||
const r = await get('/api/admin-probe', cookie);
|
||||
expect(r.statusCode).toBe(403);
|
||||
expect(r.json().error.code).toBe('forbidden');
|
||||
});
|
||||
|
||||
it('no cookie → 401; logout ends the session', async () => {
|
||||
expect((await get('/api/auth/me')).statusCode).toBe(401);
|
||||
const cookie = cookieOf(await login(ADMIN.email, adminPassword));
|
||||
const out = await t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/logout',
|
||||
headers: { ...H, cookie },
|
||||
});
|
||||
expect(out.statusCode).toBe(200);
|
||||
expect((await get('/api/auth/me', cookie)).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('inactive users cannot log in', async () => {
|
||||
const u = await t.addUser('gone@example.com', 'gone-password-123');
|
||||
await t.db.update(users).set({ isActive: false }).where(eq(users.id, u.id));
|
||||
expect((await login('gone@example.com', 'gone-password-123')).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('every login event is audited', async () => {
|
||||
const actions = (await t.db.select({ a: auditLog.action }).from(auditLog)).map((r) => r.a);
|
||||
expect(actions).toEqual(
|
||||
expect.arrayContaining(['create', 'login', 'login_failed', 'logout', 'password_changed']),
|
||||
);
|
||||
});
|
||||
|
||||
it('healthz checks the DB; the SPA fallback skips /api and /prov', async () => {
|
||||
expect((await get('/healthz')).json()).toEqual({ ok: true });
|
||||
expect((await get('/admin/users')).body).toBe('<h1>spa</h1>');
|
||||
expect((await get('/api/nope')).statusCode).toBe(404);
|
||||
expect((await get('/prov/x.cfg')).statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PASSWORD_MIN = 10;
|
||||
|
||||
export const LoginSchema = z.object({
|
||||
email: z.string().trim().min(3).max(254),
|
||||
password: z.string().min(1).max(200),
|
||||
totp: z
|
||||
.string()
|
||||
.regex(/^\d{6}$/)
|
||||
.optional(), // M11
|
||||
});
|
||||
|
||||
export const ChangePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1).max(200),
|
||||
newPassword: z.string().min(PASSWORD_MIN).max(200),
|
||||
});
|
||||
|
||||
export type Role = 'admin' | 'user';
|
||||
|
||||
/** What /api/auth/login and /api/auth/me return. */
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: Role;
|
||||
mustChangePassword: boolean;
|
||||
extension: { id: string; number: string; name: string } | null;
|
||||
};
|
||||
@@ -3,3 +3,4 @@ export const APP_NAME = 'SelfPhoneNG';
|
||||
export * from './phone.ts';
|
||||
export * from './destination.ts';
|
||||
export * from './settings.ts';
|
||||
export * from './auth.ts';
|
||||
Generated
+155
@@ -41,9 +41,15 @@ importers:
|
||||
|
||||
apps/api:
|
||||
dependencies:
|
||||
'@fastify/cookie':
|
||||
specifier: ^11.1.3
|
||||
version: 11.1.3
|
||||
'@fastify/static':
|
||||
specifier: ^10.1.6
|
||||
version: 10.1.6
|
||||
'@node-rs/argon2':
|
||||
specifier: ^2.2.2
|
||||
version: 2.2.2
|
||||
'@selfphoneng/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
@@ -685,6 +691,9 @@ packages:
|
||||
'@fastify/ajv-compiler@4.0.7':
|
||||
resolution: {integrity: sha512-VP0yIfwlc1ohj733keYDqyHm1VWeP9VQxYomunhr90jkC4aNNibbkrRiaNgPgqj1ORxUenILR4qzA/ppYBNAtQ==}
|
||||
|
||||
'@fastify/cookie@11.1.3':
|
||||
resolution: {integrity: sha512-RBhlMNsmSG6C5KGiTkhSXBF75xIrUMSytDDBcpnVewhJ8XVYnJTu+SV6FYKXClIRsBuRjOfKbBSULvnPD3X/mg==}
|
||||
|
||||
'@fastify/deepmerge@3.2.2':
|
||||
resolution: {integrity: sha512-155eqA/WMQdN6gUD4RrhS2K0Pcz2Ou9pQ86L0sipOpP9ONbTA6oYw/hW4VdBjR4egcDHqmWUK9x80oUX7S1mDQ==}
|
||||
|
||||
@@ -758,6 +767,92 @@ packages:
|
||||
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@node-rs/argon2-android-arm-eabi@2.2.2':
|
||||
resolution: {integrity: sha512-c11BOrPLBWNC1cDyzGB+Bil+flmxfZbB2hv8EhcobmAsFKMyqkHPNhUY4qhcjv1U+4+Vmw4G8H+l224iO2g/Dw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@node-rs/argon2-android-arm64@2.2.2':
|
||||
resolution: {integrity: sha512-dpMYIoxhN/wY6hFH+3MM5kQErcH3luMekkszbkDF3NKqhB1kFRntkOLDpFY87oItBSHJrsoYkXC3vBRhjYoCjQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@node-rs/argon2-darwin-arm64@2.2.2':
|
||||
resolution: {integrity: sha512-vxVAXcIG0iiHizkCuImmzVhOFsRnU4Z79xiOxdISLyI+QhOAWbbTaVSXw0PNfThB3zAdkzzDJ/WWu52Hs/qHGg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@node-rs/argon2-darwin-x64@2.2.2':
|
||||
resolution: {integrity: sha512-r7GbSFpFZZ9EAV9cvIm9prUjOupK7QQXTyP+FJ6bWSMq0h+qRDBlUOXQXduDIIrLEGoBVelixKUtf4ksZ0ljoQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@node-rs/argon2-freebsd-x64@2.2.2':
|
||||
resolution: {integrity: sha512-AmWxDSmQgprilD6NX3jmxCgcDvWAW7kGJnnWQo+0qav6nfMXdPZYgSezK/skmw1GBWipr4yZMIUzpeNHnVpMxg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@node-rs/argon2-linux-arm-gnueabihf@2.2.2':
|
||||
resolution: {integrity: sha512-M1gnPBTr9GFeEUWkQ53qCJeTsX03C3kcUeHnDbN7dU2xkI58wMP4Z1sEBN7WmKJ/fTHYEgti4truz7r4A82DLg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@node-rs/argon2-linux-arm64-gnu@2.2.2':
|
||||
resolution: {integrity: sha512-4CYDlIdITlLSVcAXYUtEoGUahRaayPXM+4Rcfg3BZx9WGi6UB8HUycs6GNMTPYXV5kVXCpyE2u1HmYUpgmIzOQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@node-rs/argon2-linux-arm64-musl@2.2.2':
|
||||
resolution: {integrity: sha512-FYlfj3XgQ1xrx6HzQbKbUcKiYMG/3iSi/X81KyhHaItTJ7vZlMwrbVLl9yqDmvotKUF6VjrhrlZ8KRbbzchseA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@node-rs/argon2-linux-x64-gnu@2.2.2':
|
||||
resolution: {integrity: sha512-ULXLs8UFYZANlwiROM7AeccxZndr2Xk3IstADq3jAUhnwV2W75mxMBTABUZ+7AdKRhPLOrxU77OgnMWlDkYJQg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@node-rs/argon2-linux-x64-musl@2.2.2':
|
||||
resolution: {integrity: sha512-zgjIwc9TyGodn62vGc+36DGwFqVV5e7L8Ptd2FQWuBxe5aFrcFxvtDGn1/qfYwoHLv09utgtR+RBAPCXepWG3Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@node-rs/argon2-win32-arm64-msvc@2.2.2':
|
||||
resolution: {integrity: sha512-3nLhvLie/ec5xfxUBXXqGMb7oGeqF+YXrcGckqaTaGkPtOBozho0Kh2IuP/zi9BcOriirTG2LW3gtIOfpuh+mQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@node-rs/argon2-win32-ia32-msvc@2.2.2':
|
||||
resolution: {integrity: sha512-6RJKD6cFl7UeiYJLjRsXPywVXSFSaSDdr6xGpztNDV31QHCEecWnsnNmKxzi4gVpln2F9XeWEtIgM4Nqw1hLbA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@node-rs/argon2-win32-x64-msvc@2.2.2':
|
||||
resolution: {integrity: sha512-dQOO9S4QKBaVul0Uyk/2quJc/OBeHNZRJP+GZZTy1A5kYIaAfIJGHUYLY6oFoAlcuLIkdFouLaHX3fCl8tZm/w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@node-rs/argon2@2.2.2':
|
||||
resolution: {integrity: sha512-NtdlPPWtR5U4BxX5EGJYza+UklNaoWORmkaKCMqf/nyKeXWKVwNt8KfQoRpNT7OB1DcEwmePafO4wZyW+sbfCQ==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@oxc-project/types@0.154.0':
|
||||
resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==}
|
||||
|
||||
@@ -2696,6 +2791,11 @@ snapshots:
|
||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||
fast-uri: 4.2.2
|
||||
|
||||
'@fastify/cookie@11.1.3':
|
||||
dependencies:
|
||||
cookie: 2.0.1
|
||||
fastify-plugin: 6.0.1
|
||||
|
||||
'@fastify/deepmerge@3.2.2': {}
|
||||
|
||||
'@fastify/error@4.2.1': {}
|
||||
@@ -2778,6 +2878,61 @@ snapshots:
|
||||
|
||||
'@lukeed/ms@2.0.2': {}
|
||||
|
||||
'@node-rs/argon2-android-arm-eabi@2.2.2':
|
||||
optional: true
|
||||
|
||||
'@node-rs/argon2-android-arm64@2.2.2':
|
||||
optional: true
|
||||
|
||||
'@node-rs/argon2-darwin-arm64@2.2.2':
|
||||
optional: true
|
||||
|
||||
'@node-rs/argon2-darwin-x64@2.2.2':
|
||||
optional: true
|
||||
|
||||
'@node-rs/argon2-freebsd-x64@2.2.2':
|
||||
optional: true
|
||||
|
||||
'@node-rs/argon2-linux-arm-gnueabihf@2.2.2':
|
||||
optional: true
|
||||
|
||||
'@node-rs/argon2-linux-arm64-gnu@2.2.2':
|
||||
optional: true
|
||||
|
||||
'@node-rs/argon2-linux-arm64-musl@2.2.2':
|
||||
optional: true
|
||||
|
||||
'@node-rs/argon2-linux-x64-gnu@2.2.2':
|
||||
optional: true
|
||||
|
||||
'@node-rs/argon2-linux-x64-musl@2.2.2':
|
||||
optional: true
|
||||
|
||||
'@node-rs/argon2-win32-arm64-msvc@2.2.2':
|
||||
optional: true
|
||||
|
||||
'@node-rs/argon2-win32-ia32-msvc@2.2.2':
|
||||
optional: true
|
||||
|
||||
'@node-rs/argon2-win32-x64-msvc@2.2.2':
|
||||
optional: true
|
||||
|
||||
'@node-rs/argon2@2.2.2':
|
||||
optionalDependencies:
|
||||
'@node-rs/argon2-android-arm-eabi': 2.2.2
|
||||
'@node-rs/argon2-android-arm64': 2.2.2
|
||||
'@node-rs/argon2-darwin-arm64': 2.2.2
|
||||
'@node-rs/argon2-darwin-x64': 2.2.2
|
||||
'@node-rs/argon2-freebsd-x64': 2.2.2
|
||||
'@node-rs/argon2-linux-arm-gnueabihf': 2.2.2
|
||||
'@node-rs/argon2-linux-arm64-gnu': 2.2.2
|
||||
'@node-rs/argon2-linux-arm64-musl': 2.2.2
|
||||
'@node-rs/argon2-linux-x64-gnu': 2.2.2
|
||||
'@node-rs/argon2-linux-x64-musl': 2.2.2
|
||||
'@node-rs/argon2-win32-arm64-msvc': 2.2.2
|
||||
'@node-rs/argon2-win32-ia32-msvc': 2.2.2
|
||||
'@node-rs/argon2-win32-x64-msvc': 2.2.2
|
||||
|
||||
'@oxc-project/types@0.154.0': {}
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
Reference in new issue
Block a user