Initial commit with Email Import Wizard and Task Processor updates
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyGuard implements CanActivate {
|
||||
constructor(private readonly apiKeysService: ApiKeysService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
// Check header (X-API-Key)
|
||||
let apiKey = request.headers['x-api-key'] || request.headers['X-API-Key'];
|
||||
|
||||
// Fallback to query parameter (apiKey)
|
||||
if (!apiKey) {
|
||||
apiKey = request.query['apiKey'];
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
throw new UnauthorizedException('API Key missing');
|
||||
}
|
||||
|
||||
try {
|
||||
const keyEntry = await this.apiKeysService.validateKey(apiKey as string);
|
||||
|
||||
// Attach metadata to request if needed later
|
||||
request.apiKeyMetadata = {
|
||||
id: keyEntry.id,
|
||||
name: keyEntry.name,
|
||||
};
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
throw new UnauthorizedException(err.message || 'Invalid API Key');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
@Controller('api/api-keys')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ApiKeysController {
|
||||
constructor(private readonly apiKeysService: ApiKeysService) {}
|
||||
|
||||
@Post()
|
||||
async create(@Body() body: { name: string; expiresDays?: number }) {
|
||||
// Note: The plainKey is only returned here once.
|
||||
return this.apiKeysService.createApiKey(body.name, body.expiresDays);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findAll() {
|
||||
// Note: This returns hashed keys and prefixes, not the plain keys.
|
||||
return this.apiKeysService.listKeys();
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id') id: string) {
|
||||
return this.apiKeysService.deleteKey(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ApiKey } from '../database/entities/api-key.entity';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeysService {
|
||||
constructor(
|
||||
@InjectRepository(ApiKey)
|
||||
private readonly apiKeyRepo: Repository<ApiKey>,
|
||||
) {}
|
||||
|
||||
async createApiKey(name: string, expiresDays?: number): Promise<{ plainKey: string; entity: ApiKey }> {
|
||||
const prefix = 'pm_';
|
||||
const randomPart = crypto.randomBytes(24).toString('hex'); // 48 chars hex
|
||||
const plainKey = `${prefix}${randomPart}`;
|
||||
|
||||
const keyHash = this.hashKey(plainKey);
|
||||
|
||||
const apiKey = this.apiKeyRepo.create({
|
||||
name,
|
||||
keyPrefix: prefix,
|
||||
keyHash,
|
||||
expiresAt: expiresDays ? new Date(Date.now() + expiresDays * 24 * 60 * 60 * 1000) : null,
|
||||
});
|
||||
|
||||
const savedKey = await this.apiKeyRepo.save(apiKey);
|
||||
|
||||
return {
|
||||
plainKey,
|
||||
entity: savedKey,
|
||||
};
|
||||
}
|
||||
|
||||
async validateKey(plainKey: string): Promise<ApiKey> {
|
||||
const keyHash = this.hashKey(plainKey);
|
||||
|
||||
const apiKey = await this.apiKeyRepo.findOne({
|
||||
where: { keyHash },
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
throw new UnauthorizedException('Invalid API Key');
|
||||
}
|
||||
|
||||
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException('API Key has expired');
|
||||
}
|
||||
|
||||
// Update last used timestamp (async, don't wait for it to return response faster)
|
||||
apiKey.lastUsedAt = new Date();
|
||||
this.apiKeyRepo.save(apiKey).catch(err => console.error('Error updating lastUsedAt:', err));
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
async listKeys(): Promise<ApiKey[]> {
|
||||
return this.apiKeyRepo.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteKey(id: string): Promise<void> {
|
||||
await this.apiKeyRepo.delete(id);
|
||||
}
|
||||
|
||||
private hashKey(key: string): string {
|
||||
return crypto.createHash('sha256').update(key).digest('hex');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
import { ApiKeysController } from './api-keys.controller';
|
||||
import { ApiKeyGuard } from './api-key.guard';
|
||||
import { JwtOrApiKeyGuard } from './jwt-or-apikey.guard';
|
||||
import { ApiKey } from '../database/entities/api-key.entity';
|
||||
import { PermissionsGuard } from './permissions.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
TypeOrmModule.forFeature([ApiKey]),
|
||||
],
|
||||
controllers: [ApiKeysController],
|
||||
providers: [
|
||||
JwtStrategy,
|
||||
JwtAuthGuard,
|
||||
ApiKeysService,
|
||||
ApiKeyGuard,
|
||||
JwtOrApiKeyGuard,
|
||||
PermissionsGuard,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: PermissionsGuard,
|
||||
},
|
||||
],
|
||||
exports: [PassportModule, ApiKeysService, ApiKeyGuard, JwtAuthGuard, JwtOrApiKeyGuard, PermissionsGuard, TypeOrmModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) return true;
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { ApiKeyGuard } from './api-key.guard';
|
||||
import { lastValueFrom, isObservable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Combined guard that accepts either a valid JWT Bearer token
|
||||
* or a valid API key (X-API-Key header / apiKey query param).
|
||||
* Tries JWT first, falls back to API key.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtOrApiKeyGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtGuard: JwtAuthGuard,
|
||||
private readonly apiKeyGuard: ApiKeyGuard,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
// Try JWT first
|
||||
try {
|
||||
const result = this.jwtGuard.canActivate(context);
|
||||
const jwtOk = isObservable(result) ? await lastValueFrom(result) : await result;
|
||||
if (jwtOk) return true;
|
||||
} catch {
|
||||
// JWT failed, try API key
|
||||
}
|
||||
|
||||
// Fall back to API key
|
||||
return this.apiKeyGuard.canActivate(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy, ExtractJwt } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { passportJwtSecret } from 'jwks-rsa';
|
||||
import { mapGroupsToPermissions } from './permissions.enum';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(configService: ConfigService) {
|
||||
const issuer = configService.get<string>('OIDC_ISSUER', '');
|
||||
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
issuer,
|
||||
algorithms: ['RS256'],
|
||||
secretOrKeyProvider: passportJwtSecret({
|
||||
cache: true,
|
||||
rateLimit: true,
|
||||
jwksRequestsPerMinute: 5,
|
||||
jwksUri: `${issuer.endsWith('/') ? issuer.slice(0, -1) : issuer}/jwks/`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: any): { userId: string; email: string; name: string; preferredUsername: string | null; groups: string[]; permissions: any[] } {
|
||||
const groups = payload.groups || [];
|
||||
return {
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
name: payload.name || payload.preferred_username,
|
||||
preferredUsername: payload.preferred_username ?? null,
|
||||
groups: groups,
|
||||
permissions: mapGroupsToPermissions(groups),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { Permission } from './permissions.enum';
|
||||
|
||||
export const PERMISSIONS_KEY = 'permissions';
|
||||
export const RequirePermissions = (...permissions: Permission[]) => SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
@@ -0,0 +1,35 @@
|
||||
export const Permission = {
|
||||
MANAGE_ALL: 'MANAGE_ALL',
|
||||
PROCESS_MANUALLY: 'PROCESS_MANUALLY',
|
||||
VIEW_MAIL: 'VIEW_MAIL',
|
||||
VIEW_INBOX: 'VIEW_INBOX',
|
||||
VIEW_SCANNER: 'VIEW_SCANNER',
|
||||
MANAGE_SETTINGS: 'MANAGE_SETTINGS',
|
||||
} as const;
|
||||
|
||||
export type Permission = typeof Permission[keyof typeof Permission];
|
||||
|
||||
export function mapGroupsToPermissions(groups: string[] | undefined | null): Permission[] {
|
||||
const permissions = new Set<Permission>();
|
||||
|
||||
if (!groups || !Array.isArray(groups)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (groups.includes('PM_Admin')) {
|
||||
permissions.add(Permission.MANAGE_ALL);
|
||||
permissions.add(Permission.PROCESS_MANUALLY);
|
||||
permissions.add(Permission.VIEW_MAIL);
|
||||
permissions.add(Permission.VIEW_INBOX);
|
||||
permissions.add(Permission.VIEW_SCANNER);
|
||||
permissions.add(Permission.MANAGE_SETTINGS);
|
||||
return Array.from(permissions);
|
||||
}
|
||||
|
||||
if (groups.includes('PM_Belege')) permissions.add(Permission.PROCESS_MANUALLY);
|
||||
if (groups.includes('PM_Maileingang')) permissions.add(Permission.VIEW_MAIL);
|
||||
if (groups.includes('PM_Posteingang')) permissions.add(Permission.VIEW_INBOX);
|
||||
if (groups.includes('PM_Scanner')) permissions.add(Permission.VIEW_SCANNER);
|
||||
|
||||
return Array.from(permissions);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PERMISSIONS_KEY } from './permissions.decorator';
|
||||
import { Permission } from './permissions.enum';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(PERMISSIONS_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredPermissions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
|
||||
// Let API Key requests bypass the permissions check for now, unless explicitly denied.
|
||||
// Usually API keys have different scopes, but assuming they act as Admins for automated uploads.
|
||||
if (user && user.apiKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!user || !user.permissions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userPermissions = user.permissions as Permission[];
|
||||
|
||||
if (userPermissions.includes(Permission.MANAGE_ALL)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return requiredPermissions.some((permission) => userPermissions.includes(permission));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
Reference in New Issue
Block a user