v1.0.25
This commit is contained in:
1 parent
c210ef0a8e
commit
afee09866d
55 files changed
+7767
-131
No files matched your search
@@ -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 });
|
||||
};
|
||||
Reference in new issue
Block a user