This commit is contained in:
jarek committed 2026-04-18 08:34:20 +02:00
1 parent c210ef0a8e
commit afee09866d
55 files changed
+7767 -131

No files matched your search

+164
View File
@@ -0,0 +1,164 @@
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' } });
};
@@ -0,0 +1,40 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { revokeApiToken } from '$lib/server/api-tokens';
import { isAuthEnabled } from '$lib/server/auth';
import { audit } from '$lib/server/audit';
/**
* DELETE /api/auth/tokens/[id] - Revoke an API token
*/
export const DELETE: RequestHandler = async (event) => {
const { cookies, params } = 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 });
}
const tokenId = parseInt(params.id);
if (isNaN(tokenId)) {
return json({ error: 'Invalid token ID' }, { status: 400 });
}
const success = await revokeApiToken(tokenId, auth.user.id, auth.isAdmin);
if (!success) {
return json({ error: 'Token not found or access denied' }, { status: 404 });
}
await audit(event, 'delete', 'api_token', {
entityId: params.id,
description: `API token revoked`
});
return json({ success: true });
};