feat(api-keys): restrict keys to source IP addresses
A key can be limited to IP addresses or CIDR networks (e.g. the Wabenhain server); a stolen key is useless from anywhere else. Requests from other addresses get 403 "not allowed for this IP" and a log line. The admin UI asks for the addresses when creating a key, warns when a key works from anywhere, shows the last source address and can take it over into the allowlist - changing the list needs no new key (PATCH /api-keys/:id). Also: a key can only carry scopes its creator holds. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
9ae1d96a32
commit
f1dc0de3e2
16 files changed
+463
-10
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
-- API keys can be limited to source addresses (IP or CIDR).
|
||||||
|
ALTER TABLE "ApiKey" ADD COLUMN "allowedIps" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
|
ALTER TABLE "ApiKey" ADD COLUMN "lastUsedIp" TEXT;
|
||||||
@@ -147,6 +147,10 @@ model ApiKey {
|
|||||||
createdByUser User? @relation(fields: [createdByUserId], references: [id])
|
createdByUser User? @relation(fields: [createdByUserId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
lastUsedAt DateTime?
|
lastUsedAt DateTime?
|
||||||
|
// Absender-IP der letzten Nutzung - hilft beim Einrichten der Allowlist.
|
||||||
|
lastUsedIp String?
|
||||||
|
// Nur von diesen Adressen/Netzen (IP oder CIDR) gueltig; leer = ueberall.
|
||||||
|
allowedIps String[] @default([])
|
||||||
expiresAt DateTime?
|
expiresAt DateTime?
|
||||||
revokedAt DateTime?
|
revokedAt DateTime?
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||||
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||||
|
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
|
||||||
import {
|
import {
|
||||||
ApiKeySummary,
|
ApiKeySummary,
|
||||||
ApiKeysService,
|
ApiKeysService,
|
||||||
@@ -31,6 +32,16 @@ export class ApiKeysController {
|
|||||||
return this.apiKeys.create(dto, actor.id);
|
return this.apiKeys.create(dto, actor.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequirePermission('api-keys:manage')
|
||||||
|
@Patch(':id')
|
||||||
|
updateAllowedIps(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateApiKeyDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
): Promise<ApiKeySummary> {
|
||||||
|
return this.apiKeys.updateAllowedIps(id, dto, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
@RequirePermission('api-keys:manage')
|
@RequirePermission('api-keys:manage')
|
||||||
@Post(':id/revoke')
|
@Post(':id/revoke')
|
||||||
revoke(
|
revoke(
|
||||||
|
|||||||
@@ -1,10 +1,28 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { createHash, randomBytes, randomInt } from 'node:crypto';
|
import { createHash, randomBytes, randomInt } from 'node:crypto';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { ApiKey } from '../../generated/prisma';
|
import { ApiKey } from '../../generated/prisma';
|
||||||
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||||
import { ApiKeyScope, isApiKeyScope } from './api-key-scopes';
|
import { ApiKeyScope, isApiKeyScope } from './api-key-scopes';
|
||||||
|
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
|
||||||
|
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
|
||||||
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
|
||||||
|
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
(entries ?? [])
|
||||||
|
.map(normalizeAllowlistEntry)
|
||||||
|
.filter((entry): entry is string => entry !== null),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// ack_<8 lowercase chars>_<32 random bytes, base64url>
|
// ack_<8 lowercase chars>_<32 random bytes, base64url>
|
||||||
const TOKEN_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/;
|
const TOKEN_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/;
|
||||||
@@ -48,9 +66,12 @@ function toSummary(apiKey: ApiKey): ApiKeySummary {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApiKeysService {
|
export class ApiKeysService {
|
||||||
|
private readonly logger = new Logger(ApiKeysService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(): Promise<ApiKeySummary[]> {
|
async findAll(): Promise<ApiKeySummary[]> {
|
||||||
@@ -64,6 +85,15 @@ export class ApiKeysService {
|
|||||||
dto: CreateApiKeyDto,
|
dto: CreateApiKeyDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<CreatedApiKey> {
|
): Promise<CreatedApiKey> {
|
||||||
|
// Nobody hands a machine more than they may do themselves.
|
||||||
|
const callerPermissions =
|
||||||
|
await this.permissionResolver.getPermissions(actingUserId);
|
||||||
|
const missing = dto.scopes.filter((scope) => !callerPermissions.has(scope));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Diese Berechtigung haben Sie selbst nicht: ${missing.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const prefix = randomPrefix();
|
const prefix = randomPrefix();
|
||||||
// 32 random bytes: a plain hash of it is safe to store without a slow
|
// 32 random bytes: a plain hash of it is safe to store without a slow
|
||||||
// KDF - there is nothing guessable to brute-force.
|
// KDF - there is nothing guessable to brute-force.
|
||||||
@@ -79,6 +109,7 @@ export class ApiKeysService {
|
|||||||
prefix,
|
prefix,
|
||||||
keyHash: hashToken(token),
|
keyHash: hashToken(token),
|
||||||
scopes: [...new Set(dto.scopes)],
|
scopes: [...new Set(dto.scopes)],
|
||||||
|
allowedIps: normalizeAllowlist(dto.allowedIps),
|
||||||
expiresAt,
|
expiresAt,
|
||||||
createdByUserId: actingUserId,
|
createdByUserId: actingUserId,
|
||||||
},
|
},
|
||||||
@@ -94,6 +125,31 @@ export class ApiKeysService {
|
|||||||
return { apiKey: summary, token };
|
return { apiKey: summary, token };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Changes where a key may be used from, without issuing a new one. */
|
||||||
|
async updateAllowedIps(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateApiKeyDto,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<ApiKeySummary> {
|
||||||
|
const before = await this.prisma.apiKey.findUnique({ where: { id } });
|
||||||
|
if (!before) {
|
||||||
|
throw new NotFoundException('API-Schlüssel nicht gefunden');
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.apiKey.update({
|
||||||
|
where: { id },
|
||||||
|
data: { allowedIps: normalizeAllowlist(dto.allowedIps) },
|
||||||
|
});
|
||||||
|
await this.auditLog.record({
|
||||||
|
entityType: 'ApiKey',
|
||||||
|
entityId: id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
beforeData: toSummary(before),
|
||||||
|
afterData: toSummary(updated),
|
||||||
|
});
|
||||||
|
return toSummary(updated);
|
||||||
|
}
|
||||||
|
|
||||||
async revoke(id: string, actingUserId: string): Promise<ApiKeySummary> {
|
async revoke(id: string, actingUserId: string): Promise<ApiKeySummary> {
|
||||||
const before = await this.prisma.apiKey.findUnique({ where: { id } });
|
const before = await this.prisma.apiKey.findUnique({ where: { id } });
|
||||||
if (!before) {
|
if (!before) {
|
||||||
@@ -121,7 +177,10 @@ export class ApiKeysService {
|
|||||||
* Resolves a presented key to what it may do, or null for anything that
|
* Resolves a presented key to what it may do, or null for anything that
|
||||||
* must not pass: malformed, unknown, revoked or expired.
|
* must not pass: malformed, unknown, revoked or expired.
|
||||||
*/
|
*/
|
||||||
async authenticate(token: string): Promise<ApiKeyPrincipal | null> {
|
async authenticate(
|
||||||
|
token: string,
|
||||||
|
clientIp?: string,
|
||||||
|
): Promise<ApiKeyPrincipal | null> {
|
||||||
if (!TOKEN_PATTERN.test(token)) {
|
if (!TOKEN_PATTERN.test(token)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -136,13 +195,24 @@ export class ApiKeysService {
|
|||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (!isIpAllowed(clientIp, apiKey.allowedIps)) {
|
||||||
|
// The key itself is right: say so, so the admin finds the cause - a
|
||||||
|
// thief learns nothing new, they already hold the key.
|
||||||
|
this.logger.warn(
|
||||||
|
`API key ${apiKey.prefix} used from ${clientIp ?? 'unknown'}, not in its allowlist`,
|
||||||
|
);
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'API-Schlüssel ist für diese IP-Adresse nicht freigegeben',
|
||||||
|
);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!apiKey.lastUsedAt ||
|
!apiKey.lastUsedAt ||
|
||||||
now.getTime() - apiKey.lastUsedAt.getTime() > LAST_USED_RESOLUTION_MS
|
now.getTime() - apiKey.lastUsedAt.getTime() > LAST_USED_RESOLUTION_MS ||
|
||||||
|
apiKey.lastUsedIp !== (clientIp ?? null)
|
||||||
) {
|
) {
|
||||||
await this.prisma.apiKey.update({
|
await this.prisma.apiKey.update({
|
||||||
where: { id: apiKey.id },
|
where: { id: apiKey.id },
|
||||||
data: { lastUsedAt: now },
|
data: { lastUsedAt: now, lastUsedIp: clientIp ?? null },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Intersect with today's allowlist: narrowing it later also narrows
|
// Intersect with today's allowlist: narrowing it later also narrows
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {
|
||||||
|
ValidationArguments,
|
||||||
|
ValidatorConstraint,
|
||||||
|
ValidatorConstraintInterface,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { normalizeAllowlistEntry } from '../ip-allowlist';
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: 'allowedIp' })
|
||||||
|
export class AllowedIpConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(value: unknown): boolean {
|
||||||
|
return typeof value === 'string' && normalizeAllowlistEntry(value) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(args: ValidationArguments): string {
|
||||||
|
return `${String(args.value)} ist keine IP-Adresse und kein Netz (z. B. 203.0.113.7 oder 203.0.113.0/24)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
ArrayUnique,
|
ArrayUnique,
|
||||||
IsArray,
|
IsArray,
|
||||||
@@ -11,8 +12,11 @@ import {
|
|||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
|
Validate,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { API_KEY_SCOPES } from '../api-key-scopes';
|
import { API_KEY_SCOPES } from '../api-key-scopes';
|
||||||
|
import { MAX_ALLOWED_IPS } from '../ip-allowlist';
|
||||||
|
import { AllowedIpConstraint } from './allowed-ips.validator';
|
||||||
|
|
||||||
export class CreateApiKeyDto {
|
export class CreateApiKeyDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
@@ -39,4 +43,16 @@ export class CreateApiKeyDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(730)
|
@Max(730)
|
||||||
expiresInDays?: number;
|
expiresInDays?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Erlaubte Absender (IP oder CIDR), z. B. die Adresse des Wabenhain-Servers',
|
||||||
|
type: [String],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(MAX_ALLOWED_IPS)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Validate(AllowedIpConstraint, { each: true })
|
||||||
|
allowedIps?: string[];
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ArrayMaxSize, IsArray, IsString, Validate } from 'class-validator';
|
||||||
|
import { MAX_ALLOWED_IPS } from '../ip-allowlist';
|
||||||
|
import { AllowedIpConstraint } from './allowed-ips.validator';
|
||||||
|
|
||||||
|
export class UpdateApiKeyDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
'Erlaubte Absender (IP oder CIDR); leere Liste = von überall nutzbar',
|
||||||
|
type: [String],
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(MAX_ALLOWED_IPS)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Validate(AllowedIpConstraint, { each: true })
|
||||||
|
allowedIps!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
|
||||||
|
|
||||||
|
describe('ip-allowlist', () => {
|
||||||
|
it('normalizes addresses and networks', () => {
|
||||||
|
expect(normalizeAllowlistEntry(' 203.0.113.7 ')).toBe('203.0.113.7');
|
||||||
|
expect(normalizeAllowlistEntry('203.0.113.0/24')).toBe('203.0.113.0/24');
|
||||||
|
expect(normalizeAllowlistEntry('2001:db8::/32')).toBe('2001:db8::/32');
|
||||||
|
expect(normalizeAllowlistEntry('::ffff:203.0.113.7')).toBe('203.0.113.7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects anything else', () => {
|
||||||
|
for (const entry of [
|
||||||
|
'',
|
||||||
|
'example.com',
|
||||||
|
'203.0.113.0/33',
|
||||||
|
'1.2.3.4/x',
|
||||||
|
'1.2.3.4/8/1',
|
||||||
|
]) {
|
||||||
|
expect(normalizeAllowlistEntry(entry)).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows every address when the list is empty', () => {
|
||||||
|
expect(isIpAllowed('198.51.100.1', [])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows only listed addresses and networks', () => {
|
||||||
|
const list = ['203.0.113.7', '198.51.100.0/24'];
|
||||||
|
expect(isIpAllowed('203.0.113.7', list)).toBe(true);
|
||||||
|
expect(isIpAllowed('::ffff:198.51.100.42', list)).toBe(true);
|
||||||
|
expect(isIpAllowed('203.0.113.8', list)).toBe(false);
|
||||||
|
expect(isIpAllowed(undefined, list)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import * as net from 'node:net';
|
||||||
|
|
||||||
|
/** Max entries per key - an allowlist is a short list of servers. */
|
||||||
|
export const MAX_ALLOWED_IPS = 20;
|
||||||
|
|
||||||
|
function unmapIpv4(ip: string): string {
|
||||||
|
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
|
||||||
|
return mapped ? mapped[1] : ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "203.0.113.7", "203.0.113.0/24", "2001:db8::/32" in canonical form, or
|
||||||
|
* null if the entry is neither an address nor a network.
|
||||||
|
*/
|
||||||
|
export function normalizeAllowlistEntry(entry: string): string | null {
|
||||||
|
const trimmed = entry.trim();
|
||||||
|
const [address, prefix, ...rest] = trimmed.split('/');
|
||||||
|
if (rest.length > 0 || !address) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const ip = unmapIpv4(address);
|
||||||
|
const version = net.isIP(ip);
|
||||||
|
if (version === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (prefix === undefined) {
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
if (!/^\d{1,3}$/.test(prefix)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const bits = Number(prefix);
|
||||||
|
if (bits > (version === 4 ? 32 : 128)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${ip}/${bits}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An empty list allows every address. */
|
||||||
|
export function isIpAllowed(
|
||||||
|
clientIp: string | undefined,
|
||||||
|
allowedIps: readonly string[],
|
||||||
|
): boolean {
|
||||||
|
if (allowedIps.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!clientIp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const ip = unmapIpv4(clientIp);
|
||||||
|
const version = net.isIP(ip);
|
||||||
|
if (version === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const list = new net.BlockList();
|
||||||
|
for (const entry of allowedIps) {
|
||||||
|
const [address, prefix] = entry.split('/');
|
||||||
|
const family = net.isIP(address) === 6 ? 'ipv6' : 'ipv4';
|
||||||
|
if (prefix === undefined) {
|
||||||
|
list.addAddress(address, family);
|
||||||
|
} else {
|
||||||
|
list.addSubnet(address, Number(prefix), family);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list.check(ip, version === 6 ? 'ipv6' : 'ipv4');
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
|
|
||||||
interface RequestWithUser {
|
interface RequestWithUser {
|
||||||
headers: { authorization?: string };
|
headers: { authorization?: string };
|
||||||
|
ip?: string;
|
||||||
user?: AuthenticatedUser;
|
user?: AuthenticatedUser;
|
||||||
apiKey?: ApiKeyPrincipal;
|
apiKey?: ApiKeyPrincipal;
|
||||||
}
|
}
|
||||||
@@ -55,7 +56,11 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
|||||||
// PermissionsGuard checks its scopes instead of a role.
|
// PermissionsGuard checks its scopes instead of a role.
|
||||||
const apiKeyToken = extractApiKeyToken(request.headers.authorization);
|
const apiKeyToken = extractApiKeyToken(request.headers.authorization);
|
||||||
if (apiKeyToken !== null) {
|
if (apiKeyToken !== null) {
|
||||||
const principal = await this.apiKeys.authenticate(apiKeyToken);
|
// request.ip honours TRUST_PROXY (see app.config.ts).
|
||||||
|
const principal = await this.apiKeys.authenticate(
|
||||||
|
apiKeyToken,
|
||||||
|
request.ip,
|
||||||
|
);
|
||||||
if (!principal) {
|
if (!principal) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,4 +190,42 @@ describe('API keys (e2e)', () => {
|
|||||||
.send({ name: 'x', scopes: ['shop-sync:read'] })
|
.send({ name: 'x', scopes: ['shop-sync:read'] })
|
||||||
.expect(403);
|
.expect(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('works only from allowed addresses and records where it was used', async () => {
|
||||||
|
const elsewhere = (
|
||||||
|
await createKey({
|
||||||
|
name: `Elsewhere ${suffix}`,
|
||||||
|
scopes: ['shop-sync:read'],
|
||||||
|
allowedIps: ['203.0.113.7'],
|
||||||
|
}).expect(201)
|
||||||
|
).body as CreatedKeyBody;
|
||||||
|
await request(server())
|
||||||
|
.get('/api/v1/shop-sync')
|
||||||
|
.set('Authorization', `Bearer ${elsewhere.token}`)
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
// Allow loopback (where this test runs) afterwards, without a new key.
|
||||||
|
await request(server())
|
||||||
|
.patch(`/api/v1/api-keys/${elsewhere.apiKey.id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({ allowedIps: ['127.0.0.0/8', '::1'] })
|
||||||
|
.expect(200);
|
||||||
|
await request(server())
|
||||||
|
.get('/api/v1/shop-sync')
|
||||||
|
.set('Authorization', `Bearer ${elsewhere.token}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const stored = await prisma.apiKey.findUniqueOrThrow({
|
||||||
|
where: { id: elsewhere.apiKey.id },
|
||||||
|
});
|
||||||
|
expect(stored.lastUsedIp).toMatch(/127\.0\.0\.1|::1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an allowlist entry that is not an address or network', async () => {
|
||||||
|
await createKey({
|
||||||
|
name: `Bad allowlist ${suffix}`,
|
||||||
|
scopes: ['shop-sync:read'],
|
||||||
|
allowedIps: ['example.com'],
|
||||||
|
}).expect(400);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseAllowlist } from "./api-key-allowlist";
|
||||||
|
|
||||||
|
describe("parseAllowlist", () => {
|
||||||
|
it("accepts commas, spaces and new lines and drops blanks", () => {
|
||||||
|
expect(parseAllowlist(" 203.0.113.7,198.51.100.0/24\n\n2001:db8::1 ; ")).toEqual([
|
||||||
|
"203.0.113.7",
|
||||||
|
"198.51.100.0/24",
|
||||||
|
"2001:db8::1",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list for empty input", () => {
|
||||||
|
expect(parseAllowlist(" ")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Globe, ShieldAlert } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { updateApiKeyAllowedIps } from "@/lib/api-keys/api";
|
||||||
|
import { ApiKey } from "@/lib/api-keys/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
/** "1.2.3.4, 5.6.7.0/24" or one per line -> list without blanks. */
|
||||||
|
export function parseAllowlist(text: string): string[] {
|
||||||
|
return text
|
||||||
|
.split(/[\s,;]+/)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageOf(error: unknown, fallback: string): string {
|
||||||
|
return error instanceof Error && error.message ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a key may be used from. Empty means "anywhere" and is shown as a
|
||||||
|
* warning - a stolen key would then work from any computer.
|
||||||
|
*/
|
||||||
|
export function ApiKeyAllowlist({ apiKey }: { apiKey: ApiKey }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [text, setText] = useState(apiKey.allowedIps.join("\n"));
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (allowedIps: string[]) => updateApiKeyAllowedIps(apiKey.id, allowedIps),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["api-keys"] });
|
||||||
|
toast.success("Erlaubte Adressen gespeichert.");
|
||||||
|
setEditing(false);
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(messageOf(error, "Adressen konnten nicht gespeichert werden.")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputId = `allowlist-${apiKey.id}`;
|
||||||
|
const lastIpMissing =
|
||||||
|
apiKey.lastUsedIp !== null && !parseAllowlist(text).includes(apiKey.lastUsedIp);
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
{apiKey.allowedIps.length > 0 ? (
|
||||||
|
<span className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Globe className="size-3.5" aria-hidden />
|
||||||
|
Nur von: <span className="font-mono">{apiKey.allowedIps.join(", ")}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 text-warning-foreground">
|
||||||
|
<ShieldAlert className="size-3.5 text-warning" aria-hidden />
|
||||||
|
Von jeder Adresse nutzbar – besser auf den Server von Wabenhain beschränken.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button type="button" size="sm" variant="ghost" onClick={() => setEditing(true)}>
|
||||||
|
Adressen ändern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-2 rounded-lg border border-border/60 p-3">
|
||||||
|
<Label htmlFor={inputId}>Erlaubte IP-Adressen oder Netze</Label>
|
||||||
|
<textarea
|
||||||
|
id={inputId}
|
||||||
|
rows={3}
|
||||||
|
value={text}
|
||||||
|
onChange={(event) => setText(event.target.value)}
|
||||||
|
placeholder={"203.0.113.7\n198.51.100.0/24"}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-2 font-mono text-sm"
|
||||||
|
aria-describedby={`${inputId}-hint`}
|
||||||
|
/>
|
||||||
|
<p id={`${inputId}-hint`} className="text-xs text-muted-foreground">
|
||||||
|
Eine pro Zeile. Leer lassen = von überall nutzbar.
|
||||||
|
{apiKey.lastUsedIp ? ` Zuletzt benutzt von ${apiKey.lastUsedIp}.` : ""}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{lastIpMissing ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setText((current) => `${current.trim()}\n${apiKey.lastUsedIp}`.trim())}
|
||||||
|
>
|
||||||
|
{apiKey.lastUsedIp} übernehmen
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
onClick={() => mutation.mutate(parseAllowlist(text))}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setText(apiKey.allowedIps.join("\n"));
|
||||||
|
setEditing(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||||
|
import { ApiKeyAllowlist, parseAllowlist } from "./api-key-allowlist";
|
||||||
|
|
||||||
const DEFAULT_SCOPES: ApiKeyScope[] = ["shop-sync:read"];
|
const DEFAULT_SCOPES: ApiKeyScope[] = ["shop-sync:read"];
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export function ApiKeysTab() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [name, setName] = useState("Wabenhain-Shop");
|
const [name, setName] = useState("Wabenhain-Shop");
|
||||||
const [expiresInDays, setExpiresInDays] = useState("");
|
const [expiresInDays, setExpiresInDays] = useState("");
|
||||||
|
const [allowedIps, setAllowedIps] = useState("");
|
||||||
const [created, setCreated] = useState<{ name: string; token: string } | null>(null);
|
const [created, setCreated] = useState<{ name: string; token: string } | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
@@ -51,7 +53,12 @@ export function ApiKeysTab() {
|
|||||||
setCopied(false);
|
setCopied(false);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["api-keys"] });
|
void queryClient.invalidateQueries({ queryKey: ["api-keys"] });
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Schlüssel konnte nicht angelegt werden."),
|
onError: (error) =>
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error && error.message
|
||||||
|
? error.message
|
||||||
|
: "Schlüssel konnte nicht angelegt werden.",
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const revokeMutation = useMutation({
|
const revokeMutation = useMutation({
|
||||||
@@ -66,7 +73,12 @@ export function ApiKeysTab() {
|
|||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const days = expiresInDays ? Number(expiresInDays) : undefined;
|
const days = expiresInDays ? Number(expiresInDays) : undefined;
|
||||||
createMutation.mutate({ name: name.trim(), scopes: DEFAULT_SCOPES, expiresInDays: days });
|
createMutation.mutate({
|
||||||
|
name: name.trim(),
|
||||||
|
scopes: DEFAULT_SCOPES,
|
||||||
|
expiresInDays: days,
|
||||||
|
allowedIps: parseAllowlist(allowedIps),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyToken() {
|
async function copyToken() {
|
||||||
@@ -141,13 +153,26 @@ export function ApiKeysTab() {
|
|||||||
onChange={(event) => setExpiresInDays(event.target.value)}
|
onChange={(event) => setExpiresInDays(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex min-w-56 flex-1 flex-col gap-1.5">
|
||||||
|
<Label htmlFor="api-key-ips">Nur von diesen IP-Adressen (empfohlen)</Label>
|
||||||
|
<Input
|
||||||
|
id="api-key-ips"
|
||||||
|
className="h-11 font-mono"
|
||||||
|
placeholder="z. B. 203.0.113.7"
|
||||||
|
value={allowedIps}
|
||||||
|
onChange={(event) => setAllowedIps(event.target.value)}
|
||||||
|
aria-describedby="api-key-ips-hint"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Button type="submit" size="touch" disabled={createMutation.isPending || !name.trim()}>
|
<Button type="submit" size="touch" disabled={createMutation.isPending || !name.trim()}>
|
||||||
<KeyRound aria-hidden />
|
<KeyRound aria-hidden />
|
||||||
{createMutation.isPending ? "Wird angelegt…" : "Schlüssel anlegen"}
|
{createMutation.isPending ? "Wird angelegt…" : "Schlüssel anlegen"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<p className="-mt-3 text-xs text-muted-foreground">
|
<p id="api-key-ips-hint" className="-mt-3 text-xs text-muted-foreground">
|
||||||
Berechtigung: {API_KEY_SCOPE_LABELS["shop-sync:read"]}
|
Berechtigung: {API_KEY_SCOPE_LABELS["shop-sync:read"]}. IP-Adressen mit Komma trennen; leer
|
||||||
|
= von überall. Unbekannt? Leer lassen, einmal synchronisieren, dann hier „Adressen ändern“ –
|
||||||
|
die zuletzt benutzte Adresse lässt sich übernehmen.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
@@ -179,7 +204,9 @@ export function ApiKeysTab() {
|
|||||||
Angelegt {formatDate(apiKey.createdAt)} · zuletzt benutzt{" "}
|
Angelegt {formatDate(apiKey.createdAt)} · zuletzt benutzt{" "}
|
||||||
{formatDate(apiKey.lastUsedAt)}
|
{formatDate(apiKey.lastUsedAt)}
|
||||||
{apiKey.expiresAt ? ` · gültig bis ${formatDate(apiKey.expiresAt)}` : ""}
|
{apiKey.expiresAt ? ` · gültig bis ${formatDate(apiKey.expiresAt)}` : ""}
|
||||||
|
{apiKey.lastUsedIp ? ` · von ${apiKey.lastUsedIp}` : ""}
|
||||||
</span>
|
</span>
|
||||||
|
{state.active ? <ApiKeyAllowlist apiKey={apiKey} /> : null}
|
||||||
</div>
|
</div>
|
||||||
{state.active ? (
|
{state.active ? (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ export function createApiKey(input: CreateApiKeyInput): Promise<CreatedApiKey> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateApiKeyAllowedIps(id: string, allowedIps: string[]): Promise<ApiKey> {
|
||||||
|
return apiFetch<ApiKey>(`/api-keys/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ allowedIps }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function revokeApiKey(id: string): Promise<ApiKey> {
|
export function revokeApiKey(id: string): Promise<ApiKey> {
|
||||||
return apiFetch<ApiKey>(`/api-keys/${id}/revoke`, { method: "POST" });
|
return apiFetch<ApiKey>(`/api-keys/${id}/revoke`, { method: "POST" });
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,10 @@ export interface ApiKey {
|
|||||||
createdByUserId: string | null;
|
createdByUserId: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
lastUsedAt: string | null;
|
lastUsedAt: string | null;
|
||||||
|
/** Source address of the last use - handy for filling in the allowlist. */
|
||||||
|
lastUsedIp: string | null;
|
||||||
|
/** IP addresses / CIDR networks the key works from; empty = anywhere. */
|
||||||
|
allowedIps: string[];
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
revokedAt: string | null;
|
revokedAt: string | null;
|
||||||
}
|
}
|
||||||
@@ -20,6 +24,7 @@ export interface CreateApiKeyInput {
|
|||||||
name: string;
|
name: string;
|
||||||
scopes: ApiKeyScope[];
|
scopes: ApiKeyScope[];
|
||||||
expiresInDays?: number;
|
expiresInDays?: number;
|
||||||
|
allowedIps?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatedApiKey {
|
export interface CreatedApiKey {
|
||||||
|
|||||||
Reference in new issue
Block a user