165 lines
5.0 KiB
TypeScript
165 lines
5.0 KiB
TypeScript
import { json } from '@sveltejs/kit';
|
|
import type { RequestHandler } from './$types';
|
|
import { authorize } from '$lib/server/authorize';
|
|
import { generateApiToken, listUserTokens } from '$lib/server/api-tokens';
|
|
import { isAuthEnabled, verifyPassword } from '$lib/server/auth';
|
|
import { getUser } from '$lib/server/db';
|
|
import { audit } from '$lib/server/audit';
|
|
import { getRequestContext } from '$lib/server/request-context';
|
|
|
|
// Password confirmation rate limiting (per userId)
|
|
const pwFailCounts = new Map<number, { count: number; firstFail: number }>();
|
|
const pwCooldowns = new Map<number, number>();
|
|
const PW_FAIL_WINDOW = 60_000; // 1-minute sliding window
|
|
const PW_FAIL_MAX = 5; // max failures per window
|
|
const PW_COOLDOWN = 5 * 60 * 1000; // 5-minute cooldown
|
|
|
|
const MAX_TOKENS_PER_USER = 25;
|
|
|
|
// Periodic cleanup
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [id, until] of pwCooldowns) {
|
|
if (now > until) pwCooldowns.delete(id);
|
|
}
|
|
for (const [id, entry] of pwFailCounts) {
|
|
if (now - entry.firstFail > PW_FAIL_WINDOW) pwFailCounts.delete(id);
|
|
}
|
|
}, PW_COOLDOWN).unref?.();
|
|
|
|
function isPwRateLimited(userId: number): boolean {
|
|
const until = pwCooldowns.get(userId);
|
|
if (!until) return false;
|
|
if (Date.now() > until) {
|
|
pwCooldowns.delete(userId);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function recordPwFailure(userId: number): void {
|
|
const now = Date.now();
|
|
const entry = pwFailCounts.get(userId);
|
|
if (!entry || now - entry.firstFail > PW_FAIL_WINDOW) {
|
|
pwFailCounts.set(userId, { count: 1, firstFail: now });
|
|
return;
|
|
}
|
|
entry.count++;
|
|
if (entry.count >= PW_FAIL_MAX) {
|
|
pwCooldowns.set(userId, now + PW_COOLDOWN);
|
|
pwFailCounts.delete(userId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /api/auth/tokens - List the current user's API tokens
|
|
*/
|
|
export const GET: RequestHandler = async ({ cookies }) => {
|
|
const authEnabled = await isAuthEnabled();
|
|
if (!authEnabled) {
|
|
return json({ error: 'Authentication is not enabled' }, { status: 400 });
|
|
}
|
|
|
|
const auth = await authorize(cookies);
|
|
if (!auth.isAuthenticated || !auth.user) {
|
|
return json({ error: 'Authentication required' }, { status: 401 });
|
|
}
|
|
|
|
const tokens = await listUserTokens(auth.user.id);
|
|
return json(tokens);
|
|
};
|
|
|
|
/**
|
|
* POST /api/auth/tokens - Create a new API token
|
|
*/
|
|
export const POST: RequestHandler = async (event) => {
|
|
const { cookies, request } = event;
|
|
|
|
const authEnabled = await isAuthEnabled();
|
|
if (!authEnabled) {
|
|
return json({ error: 'Authentication is not enabled' }, { status: 400 });
|
|
}
|
|
|
|
const auth = await authorize(cookies);
|
|
if (!auth.isAuthenticated || !auth.user) {
|
|
return json({ error: 'Authentication required' }, { status: 401 });
|
|
}
|
|
|
|
// Token creation requires a cookie session — a Bearer token cannot create new tokens
|
|
const reqCtx = getRequestContext();
|
|
if (reqCtx?.authMethod === 'bearer') {
|
|
return json({ error: 'Token creation requires a session login, not a Bearer token' }, { status: 403 });
|
|
}
|
|
|
|
let body: any;
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return json({ error: 'Invalid request body' }, { status: 400 });
|
|
}
|
|
const { name, expiresAt, password } = body;
|
|
|
|
// Local users must confirm their password to create tokens
|
|
// SSO/OIDC and LDAP users skip this (they authenticated via their IdP)
|
|
if (auth.user.provider === 'local') {
|
|
if (isPwRateLimited(auth.user.id)) {
|
|
return json({ error: 'Too many failed password attempts. Try again later.' }, { status: 429 });
|
|
}
|
|
if (!password || typeof password !== 'string') {
|
|
return json({ error: 'Password is required to create an API token' }, { status: 400 });
|
|
}
|
|
const dbUser = await getUser(auth.user.id);
|
|
if (!dbUser) {
|
|
return json({ error: 'User not found' }, { status: 404 });
|
|
}
|
|
const valid = await verifyPassword(password, dbUser.passwordHash);
|
|
if (!valid) {
|
|
recordPwFailure(auth.user.id);
|
|
return json({ error: 'Invalid password' }, { status: 403 });
|
|
}
|
|
}
|
|
|
|
// L2: Per-user token count limit
|
|
const existingTokens = await listUserTokens(auth.user.id);
|
|
if (existingTokens.length >= MAX_TOKENS_PER_USER) {
|
|
return json({ error: `Maximum of ${MAX_TOKENS_PER_USER} API tokens per user` }, { status: 400 });
|
|
}
|
|
|
|
// Validate name
|
|
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
|
return json({ error: 'Token name is required' }, { status: 400 });
|
|
}
|
|
if (name.length > 255) {
|
|
return json({ error: 'Token name must be 255 characters or less' }, { status: 400 });
|
|
}
|
|
|
|
// Validate expiration
|
|
if (expiresAt) {
|
|
const expiryDate = new Date(expiresAt);
|
|
if (isNaN(expiryDate.getTime())) {
|
|
return json({ error: 'Invalid expiration date' }, { status: 400 });
|
|
}
|
|
if (expiryDate <= new Date()) {
|
|
return json({ error: 'Expiration date must be in the future' }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
const result = await generateApiToken(
|
|
auth.user.id,
|
|
name.trim(),
|
|
expiresAt || null
|
|
);
|
|
|
|
await audit(event, 'create', 'api_token', {
|
|
entityId: String(result.id),
|
|
entityName: name.trim(),
|
|
description: `API token "${name.trim()}" created`
|
|
});
|
|
|
|
return json({
|
|
id: result.id,
|
|
token: result.token,
|
|
tokenPrefix: result.tokenPrefix
|
|
}, { status: 201, headers: { 'Cache-Control': 'no-store' } });
|
|
};
|