mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
feat(core): Microsoft 365 OAuth-Integration — UserIntegration + IntegrationsModule
- Neues Prisma-Model UserIntegration (AES-256-GCM verschluesselte Tokens) - Migration: 20260312_user_integrations - EntraIdService: getIntegrationAuthUrl(), handleIntegrationCallback(), refreshIntegrationToken() — erweiterte Scopes: Mail.Read, Calendars.Read, Tasks.ReadWrite, offline_access - IntegrationsModule mit Controller + Service: GET /auth/integrations/microsoft-365 → OAuth-Flow starten GET /auth/integrations/microsoft-365/callback → Token speichern GET /users/me/integrations → Verbindungen auflisten DELETE /users/me/integrations/microsoft-365 → Verbindung trennen GET /users/me/integrations/microsoft-365/token → Token fuer CRM-Service - Env: AZURE_INTEGRATION_REDIRECT_URI, INTEGRATION_ENCRYPTION_KEY Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
237e0772e6
commit
28f6ba84b0
8 changed files with 620 additions and 0 deletions
|
|
@ -54,10 +54,36 @@ model User {
|
|||
tenantMemberships TenantMembership[]
|
||||
auditLogs AuditLog[]
|
||||
expertProfile ExpertProfile?
|
||||
integrations UserIntegration[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// UserIntegration - Drittanbieter-OAuth-Tokens pro User
|
||||
// --------------------------------------------------------
|
||||
// Speichert Access- und Refresh-Tokens fuer externe Services
|
||||
// (Microsoft 365 Graph API). Tokens werden AES-256-GCM verschluesselt.
|
||||
model UserIntegration {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
provider String @db.VarChar(50) // MICROSOFT_365
|
||||
accessToken String @map("access_token") @db.Text // AES-256-GCM verschluesselt
|
||||
refreshToken String @map("refresh_token") @db.Text // AES-256-GCM verschluesselt
|
||||
expiresAt DateTime @map("expires_at")
|
||||
scopes String[] @default([])
|
||||
msTenantId String? @map("ms_tenant_id") @db.VarChar(100)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, provider])
|
||||
@@index([userId])
|
||||
@@map("user_integrations")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// AuthProvider - Authentifizierungs-Provider pro User
|
||||
// --------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
-- Migration: 20260312_user_integrations
|
||||
-- Drittanbieter-OAuth-Token-Speicher fuer User-Integrationen (z.B. Microsoft 365)
|
||||
|
||||
CREATE TABLE "user_integrations" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"user_id" UUID NOT NULL,
|
||||
"provider" VARCHAR(50) NOT NULL,
|
||||
"access_token" TEXT NOT NULL,
|
||||
"refresh_token" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMPTZ NOT NULL,
|
||||
"scopes" TEXT[] NOT NULL DEFAULT '{}',
|
||||
"ms_tenant_id" VARCHAR(100),
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT "user_integrations_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "user_integrations_user_id_provider_key" UNIQUE ("user_id", "provider"),
|
||||
CONSTRAINT "user_integrations_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX "user_integrations_user_id_idx" ON "user_integrations"("user_id");
|
||||
|
|
@ -11,6 +11,7 @@ import { UsersModule } from './core/users/users.module';
|
|||
import { TenantsModule } from './core/tenants/tenants.module';
|
||||
import { ExpertProfileModule } from './core/expert-profile/expert-profile.module';
|
||||
import { SettingsModule } from './core/settings/settings.module';
|
||||
import { IntegrationsModule } from './core/integrations/integrations.module';
|
||||
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||
import { validateConfig } from './config/env.validation';
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ import { validateConfig } from './config/env.validation';
|
|||
TenantsModule,
|
||||
ExpertProfileModule,
|
||||
SettingsModule,
|
||||
IntegrationsModule,
|
||||
],
|
||||
providers: [
|
||||
// Global Guards: Alle Routen sind standardmaessig geschuetzt
|
||||
|
|
|
|||
|
|
@ -101,6 +101,17 @@ class EnvironmentVariables {
|
|||
@IsOptional()
|
||||
@IsString()
|
||||
AZURE_REDIRECT_URI?: string;
|
||||
|
||||
// Microsoft 365 Integration (separate Redirect URI fuer Graph-API-Scopes)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
AZURE_INTEGRATION_REDIRECT_URI?: string;
|
||||
|
||||
// AES-256-GCM Schluessel fuer Token-Verschluesselung (64 Hex-Zeichen = 32 Bytes)
|
||||
// Generieren: openssl rand -hex 32
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
INTEGRATION_ENCRYPTION_KEY?: string;
|
||||
}
|
||||
|
||||
export function validateConfig(
|
||||
|
|
|
|||
|
|
@ -14,6 +14,16 @@ import {
|
|||
} from '@azure/msal-node';
|
||||
import { RedisService } from '../../../redis/redis.service';
|
||||
|
||||
/** Token-Daten die nach Integration-Callback gespeichert werden */
|
||||
export interface M365TokenResult {
|
||||
accessToken: string;
|
||||
/** Kann leer sein wenn MSAL keinen Refresh-Token zurueckgibt */
|
||||
refreshToken: string;
|
||||
expiresAt: Date;
|
||||
scopes: string[];
|
||||
msTenantId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Informationen aus dem Microsoft ID-Token.
|
||||
*/
|
||||
|
|
@ -55,7 +65,14 @@ export class EntraIdService implements OnModuleInit {
|
|||
private readonly logger = new Logger(EntraIdService.name);
|
||||
private msalClient: ConfidentialClientApplication | null = null;
|
||||
private redirectUri = '';
|
||||
private integrationRedirectUri = '';
|
||||
private readonly scopes = ['openid', 'profile', 'email', 'User.Read'];
|
||||
private readonly integrationScopes = [
|
||||
'offline_access',
|
||||
'https://graph.microsoft.com/Mail.Read',
|
||||
'https://graph.microsoft.com/Calendars.Read',
|
||||
'https://graph.microsoft.com/Tasks.ReadWrite',
|
||||
];
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
|
|
@ -123,6 +140,11 @@ export class EntraIdService implements OnModuleInit {
|
|||
|
||||
this.msalClient = new ConfidentialClientApplication(msalConfig);
|
||||
this.redirectUri = ssoConfig.redirectUri;
|
||||
|
||||
// Integration-Redirect-URI aus Env (Fallback: SSO-URI + '/integration')
|
||||
this.integrationRedirectUri =
|
||||
this.config.get<string>('AZURE_INTEGRATION_REDIRECT_URI') ||
|
||||
ssoConfig.redirectUri.replace('/callback', '/integration/callback');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -296,4 +318,131 @@ export class EntraIdService implements OnModuleInit {
|
|||
lastName,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Microsoft 365 Integration (Graph API Scopes) ──────────────────────
|
||||
|
||||
/**
|
||||
* Authorization-URL fuer MS365 Integration generieren.
|
||||
* Verwendet erweiterte Scopes (Mail, Calendar, Tasks).
|
||||
*/
|
||||
async getIntegrationAuthUrl(state: string): Promise<string> {
|
||||
if (!this.msalClient) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Microsoft SSO ist nicht konfiguriert',
|
||||
);
|
||||
}
|
||||
|
||||
const authUrlRequest: AuthorizationUrlRequest = {
|
||||
scopes: this.integrationScopes,
|
||||
redirectUri: this.integrationRedirectUri,
|
||||
state,
|
||||
prompt: 'consent', // Immer Zustimmung anfordern fuer neue Scopes
|
||||
};
|
||||
|
||||
return this.msalClient.getAuthCodeUrl(authUrlRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization Code gegen M365-Tokens tauschen.
|
||||
* Gibt Access-Token, Refresh-Token und Ablaufzeit zurueck.
|
||||
*/
|
||||
async handleIntegrationCallback(code: string): Promise<M365TokenResult> {
|
||||
if (!this.msalClient) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Microsoft SSO ist nicht konfiguriert',
|
||||
);
|
||||
}
|
||||
|
||||
const tokenRequest: AuthorizationCodeRequest = {
|
||||
code,
|
||||
scopes: this.integrationScopes,
|
||||
redirectUri: this.integrationRedirectUri,
|
||||
};
|
||||
|
||||
const response: AuthenticationResult =
|
||||
await this.msalClient.acquireTokenByCode(tokenRequest);
|
||||
|
||||
this.logger.log('M365 Integration Token erhalten');
|
||||
|
||||
// Refresh-Token aus MSAL-Antwort (erfordert offline_access Scope)
|
||||
// In msal-node >= 2.x ist refreshToken in der Antwort verfuegbar
|
||||
const rawResponse = response as AuthenticationResult & {
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
const refreshToken = rawResponse.refreshToken ?? '';
|
||||
const tenantId =
|
||||
(response.account?.tenantId) ?? '';
|
||||
|
||||
return {
|
||||
accessToken: response.accessToken,
|
||||
refreshToken,
|
||||
expiresAt: response.expiresOn ?? new Date(Date.now() + 3600 * 1000),
|
||||
scopes: this.integrationScopes,
|
||||
msTenantId: tenantId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Access-Token per Refresh-Token erneuern (Standard OAuth2 Token Endpoint).
|
||||
* Wird von IntegrationsService aufgerufen wenn Token abgelaufen.
|
||||
*/
|
||||
async refreshIntegrationToken(
|
||||
refreshToken: string,
|
||||
): Promise<{ accessToken: string; expiresAt: Date; newRefreshToken?: string }> {
|
||||
const ssoConfig = await this.loadConfigFromRedis();
|
||||
const tenantId =
|
||||
ssoConfig?.tenantId ?? this.config.get<string>('AZURE_TENANT_ID') ?? '';
|
||||
const clientId =
|
||||
ssoConfig?.clientId ?? this.config.get<string>('AZURE_CLIENT_ID') ?? '';
|
||||
const clientSecret =
|
||||
ssoConfig?.clientSecret ??
|
||||
this.config.get<string>('AZURE_CLIENT_SECRET') ??
|
||||
'';
|
||||
|
||||
if (!tenantId || !clientId || !clientSecret) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Azure-Konfiguration fehlt fuer Token-Refresh',
|
||||
);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
scope: this.integrationScopes.join(' '),
|
||||
});
|
||||
|
||||
const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
|
||||
const resp = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = (await resp.json()) as { error_description?: string };
|
||||
throw new Error(
|
||||
`Token-Refresh fehlgeschlagen: ${err.error_description ?? resp.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
refresh_token?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
expiresAt: new Date(Date.now() + data.expires_in * 1000),
|
||||
newRefreshToken: data.refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** Integration-Redirect-URI zurueckgeben */
|
||||
getIntegrationRedirectUri(): string {
|
||||
return this.integrationRedirectUri;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Delete,
|
||||
Query,
|
||||
Res,
|
||||
Logger,
|
||||
UseGuards,
|
||||
Param,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { RedisService } from '../../redis/redis.service';
|
||||
import { EntraIdService } from '../auth/sso/entra-id.service';
|
||||
import { IntegrationsService } from './integrations.service';
|
||||
|
||||
interface JwtUser {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* IntegrationsController
|
||||
*
|
||||
* Verwaltet Drittanbieter-OAuth-Verbindungen pro User.
|
||||
* Derzeit: Microsoft 365 (Graph API Scopes).
|
||||
*
|
||||
* Routen:
|
||||
* - GET /auth/integrations/microsoft-365 → OAuth-Flow starten
|
||||
* - GET /auth/integrations/microsoft-365/callback → OAuth-Callback
|
||||
* - GET /users/me/integrations → Liste der Verbindungen
|
||||
* - DELETE /users/me/integrations/microsoft-365 → Verbindung trennen
|
||||
* - GET /integrations/:userId/microsoft-365/token → Intern: Token fuer CRM-Service
|
||||
*/
|
||||
@ApiTags('Integrations')
|
||||
@Controller()
|
||||
export class IntegrationsController {
|
||||
private readonly logger = new Logger(IntegrationsController.name);
|
||||
|
||||
constructor(
|
||||
private readonly integrationsService: IntegrationsService,
|
||||
private readonly entraIdService: EntraIdService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* GET /api/v1/auth/integrations/microsoft-365
|
||||
* OAuth2-Flow fuer Microsoft 365 starten.
|
||||
* User muss eingeloggt sein — userId wird im State gespeichert.
|
||||
*/
|
||||
@Get('auth/integrations/microsoft-365')
|
||||
@ApiOperation({ summary: 'Microsoft 365 Integration starten' })
|
||||
async initM365Integration(
|
||||
@CurrentUser() user: JwtUser,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
// State = UUID, in Redis mit userId hinterlegen (5 Min TTL)
|
||||
const state = uuidv4();
|
||||
await this.redis.set(
|
||||
`integration_state:${state}`,
|
||||
user.sub,
|
||||
300,
|
||||
);
|
||||
|
||||
const authUrl = await this.entraIdService.getIntegrationAuthUrl(state);
|
||||
this.logger.log(
|
||||
`M365-Integration OAuth-Flow gestartet fuer User ${user.sub}`,
|
||||
);
|
||||
res.redirect(authUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/auth/integrations/microsoft-365/callback
|
||||
* Callback von Microsoft — Token speichern, redirect zum Profil.
|
||||
*/
|
||||
@Get('auth/integrations/microsoft-365/callback')
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Microsoft 365 Integration Callback' })
|
||||
async m365Callback(
|
||||
@Query('code') code: string,
|
||||
@Query('state') state: string,
|
||||
@Query('error') error: string,
|
||||
@Query('error_description') errorDescription: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const frontendUrl = this.config.get<string>(
|
||||
'FRONTEND_URL',
|
||||
'http://172.20.10.59',
|
||||
);
|
||||
const successUrl = `${frontendUrl}/profile?integration=microsoft-365&status=success`;
|
||||
const errorBase = `${frontendUrl}/profile?integration=microsoft-365&status=error`;
|
||||
|
||||
if (error) {
|
||||
this.logger.warn(`M365-Integration Fehler: ${error} — ${errorDescription}`);
|
||||
res.redirect(`${errorBase}&message=${encodeURIComponent(errorDescription || error)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
res.redirect(`${errorBase}&message=Ungueltige+Antwort`);
|
||||
return;
|
||||
}
|
||||
|
||||
// State aus Redis laden (enthaelt userId)
|
||||
const userId = await this.redis.get(`integration_state:${state}`);
|
||||
if (!userId) {
|
||||
this.logger.warn('M365-Integration State ungueltig oder abgelaufen');
|
||||
res.redirect(`${errorBase}&message=Sitzung+abgelaufen`);
|
||||
return;
|
||||
}
|
||||
await this.redis.del(`integration_state:${state}`);
|
||||
|
||||
try {
|
||||
const tokenResult = await this.entraIdService.handleIntegrationCallback(code);
|
||||
await this.integrationsService.saveM365Integration(userId, tokenResult);
|
||||
this.logger.log(`M365-Integration erfolgreich fuer User ${userId}`);
|
||||
res.redirect(successUrl);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`M365-Integration Callback Fehler: ${(err as Error).message}`,
|
||||
);
|
||||
res.redirect(
|
||||
`${errorBase}&message=${encodeURIComponent('Verbindung fehlgeschlagen — bitte erneut versuchen')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/users/me/integrations
|
||||
* Liste aller Drittanbieter-Verbindungen des eingeloggten Users.
|
||||
*/
|
||||
@Get('users/me/integrations')
|
||||
@ApiOperation({ summary: 'Eigene Integrationen auflisten' })
|
||||
async listMyIntegrations(@CurrentUser() user: JwtUser) {
|
||||
const integrations = await this.integrationsService.listIntegrations(user.sub);
|
||||
return { success: true, data: integrations };
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/users/me/integrations/microsoft-365
|
||||
* Microsoft 365 Integration trennen.
|
||||
*/
|
||||
@Delete('users/me/integrations/microsoft-365')
|
||||
@ApiOperation({ summary: 'Microsoft 365 Integration trennen' })
|
||||
async disconnectM365(@CurrentUser() user: JwtUser) {
|
||||
await this.integrationsService.removeM365Integration(user.sub);
|
||||
return { success: true, message: 'Microsoft 365 Verbindung getrennt' };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/integrations/:userId/microsoft-365/token
|
||||
* INTERNER ENDPOINT fuer CRM-Service.
|
||||
* Gibt den aktuellen M365-Access-Token zurueck (refreshed automatisch).
|
||||
* Zugriff: nur PLATFORM_ADMIN oder eigener User.
|
||||
*/
|
||||
@Get('integrations/:userId/microsoft-365/token')
|
||||
@ApiOperation({ summary: 'M365 Token fuer internen Dienst (CRM-Service)' })
|
||||
async getTokenForService(
|
||||
@Param('userId') userId: string,
|
||||
@CurrentUser() caller: JwtUser,
|
||||
) {
|
||||
// Nur PLATFORM_ADMIN oder der User selbst darf diesen Endpoint nutzen
|
||||
if (caller.role !== 'PLATFORM_ADMIN' && caller.sub !== userId) {
|
||||
return { success: false, error: { message: 'Kein Zugriff' } };
|
||||
}
|
||||
|
||||
const token = await this.integrationsService.getM365Token(userId);
|
||||
return { success: true, data: token };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/users/me/integrations/microsoft-365/token
|
||||
* Eigenen M365-Token abrufen (fuer CRM-Service der den User-JWT weiterleitet).
|
||||
*/
|
||||
@Get('users/me/integrations/microsoft-365/token')
|
||||
@ApiOperation({ summary: 'Eigenen M365 Token abrufen' })
|
||||
async getMyM365Token(@CurrentUser() user: JwtUser) {
|
||||
const token = await this.integrationsService.getM365Token(user.sub);
|
||||
return { success: true, data: token };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { IntegrationsController } from './integrations.controller';
|
||||
import { IntegrationsService } from './integrations.service';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { RedisModule } from '../../redis/redis.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, RedisModule, AuthModule],
|
||||
controllers: [IntegrationsController],
|
||||
providers: [IntegrationsService],
|
||||
exports: [IntegrationsService],
|
||||
})
|
||||
export class IntegrationsModule {}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { EntraIdService, type M365TokenResult } from '../auth/sso/entra-id.service';
|
||||
|
||||
/** Antwort fuer den Frontend (ohne Tokens) */
|
||||
export interface IntegrationInfo {
|
||||
provider: 'MICROSOFT_365';
|
||||
connected: boolean;
|
||||
connectedAt?: string;
|
||||
expiresAt?: string;
|
||||
scopes?: string[];
|
||||
}
|
||||
|
||||
/** Antwort fuer den internen Token-Endpoint (CRM-Service) */
|
||||
export interface TokenResponse {
|
||||
accessToken: string;
|
||||
expiresAt: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
|
||||
@Injectable()
|
||||
export class IntegrationsService {
|
||||
private readonly logger = new Logger(IntegrationsService.name);
|
||||
private readonly encryptionKey: Buffer;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly entraId: EntraIdService,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
// Encryption Key aus Env (64 Hex-Zeichen = 32 Bytes)
|
||||
const keyHex = this.config.get<string>('INTEGRATION_ENCRYPTION_KEY');
|
||||
if (keyHex && keyHex.length === 64) {
|
||||
this.encryptionKey = Buffer.from(keyHex, 'hex');
|
||||
} else {
|
||||
// Fallback: Deterministisch aus JWT-Key ableiten (Development only)
|
||||
this.logger.warn(
|
||||
'INTEGRATION_ENCRYPTION_KEY nicht konfiguriert — verwende unsicheren Fallback (nur fuer Entwicklung!)',
|
||||
);
|
||||
// Fallback-Key: 32 Bytes aus festem String (NIE in Produktion)
|
||||
this.encryptionKey = Buffer.alloc(32, 'insight-dev-fallback-key-32bytes');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Verschluesselung ──────────────────────────────────────────────────
|
||||
|
||||
private encrypt(text: string): string {
|
||||
const iv = randomBytes(12); // 96-bit IV fuer GCM
|
||||
const cipher = createCipheriv(ALGORITHM, this.encryptionKey, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(text, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
// Format: iv:tag:encrypted (alle hex-kodiert, ':'-separiert)
|
||||
return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
}
|
||||
|
||||
private decrypt(encryptedText: string): string {
|
||||
const parts = encryptedText.split(':');
|
||||
if (parts.length !== 3) throw new Error('Ungueltig verschluesselter Wert');
|
||||
const [ivHex, tagHex, dataHex] = parts;
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const tag = Buffer.from(tagHex, 'hex');
|
||||
const data = Buffer.from(dataHex, 'hex');
|
||||
const decipher = createDecipheriv(ALGORITHM, this.encryptionKey, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
// ── CRUD ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* M365-Integration nach OAuth-Callback speichern.
|
||||
*/
|
||||
async saveM365Integration(userId: string, tokenResult: M365TokenResult): Promise<void> {
|
||||
const data = {
|
||||
provider: 'MICROSOFT_365',
|
||||
accessToken: this.encrypt(tokenResult.accessToken),
|
||||
refreshToken: this.encrypt(tokenResult.refreshToken || 'NONE'),
|
||||
expiresAt: tokenResult.expiresAt,
|
||||
scopes: tokenResult.scopes,
|
||||
msTenantId: tokenResult.msTenantId || null,
|
||||
};
|
||||
|
||||
await this.prisma.userIntegration.upsert({
|
||||
where: { userId_provider: { userId, provider: 'MICROSOFT_365' } },
|
||||
create: { userId, ...data },
|
||||
update: data,
|
||||
});
|
||||
|
||||
this.logger.log(`M365-Integration gespeichert fuer User ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* M365-Token holen (automatisches Refresh wenn abgelaufen).
|
||||
* Wird vom internen Token-Endpoint fuer den CRM-Service aufgerufen.
|
||||
*/
|
||||
async getM365Token(userId: string): Promise<TokenResponse> {
|
||||
const integration = await this.prisma.userIntegration.findUnique({
|
||||
where: { userId_provider: { userId, provider: 'MICROSOFT_365' } },
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
throw new NotFoundException(
|
||||
'Keine Microsoft 365 Integration fuer diesen User',
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const bufferMs = 5 * 60 * 1000; // 5 Minuten Puffer
|
||||
|
||||
// Token noch gueltig
|
||||
if (integration.expiresAt.getTime() - now.getTime() > bufferMs) {
|
||||
return {
|
||||
accessToken: this.decrypt(integration.accessToken),
|
||||
expiresAt: integration.expiresAt.toISOString(),
|
||||
provider: 'MICROSOFT_365',
|
||||
};
|
||||
}
|
||||
|
||||
// Token abgelaufen — refresh
|
||||
this.logger.log(`M365-Token abgelaufen fuer User ${userId} — refreshing`);
|
||||
const refreshToken = this.decrypt(integration.refreshToken);
|
||||
|
||||
if (refreshToken === 'NONE' || !refreshToken) {
|
||||
throw new InternalServerErrorException(
|
||||
'Kein Refresh-Token vorhanden — bitte Microsoft 365 neu verbinden',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshed = await this.entraId.refreshIntegrationToken(refreshToken);
|
||||
|
||||
// Aktualisierte Tokens speichern
|
||||
await this.prisma.userIntegration.update({
|
||||
where: { userId_provider: { userId, provider: 'MICROSOFT_365' } },
|
||||
data: {
|
||||
accessToken: this.encrypt(refreshed.accessToken),
|
||||
refreshToken: refreshed.newRefreshToken
|
||||
? this.encrypt(refreshed.newRefreshToken)
|
||||
: integration.refreshToken,
|
||||
expiresAt: refreshed.expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: refreshed.accessToken,
|
||||
expiresAt: refreshed.expiresAt.toISOString(),
|
||||
provider: 'MICROSOFT_365',
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`M365-Token-Refresh fehlgeschlagen fuer User ${userId}: ${(err as Error).message}`,
|
||||
);
|
||||
throw new InternalServerErrorException(
|
||||
'Microsoft 365 Token konnte nicht erneuert werden — bitte neu verbinden',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Integrationen eines Users auflisten (ohne Tokens).
|
||||
*/
|
||||
async listIntegrations(userId: string): Promise<IntegrationInfo[]> {
|
||||
const integrations = await this.prisma.userIntegration.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
provider: true,
|
||||
expiresAt: true,
|
||||
scopes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// M365 immer anzeigen (connected oder nicht)
|
||||
const m365 = integrations.find((i) => i.provider === 'MICROSOFT_365');
|
||||
return [
|
||||
{
|
||||
provider: 'MICROSOFT_365',
|
||||
connected: !!m365,
|
||||
connectedAt: m365?.createdAt.toISOString(),
|
||||
expiresAt: m365?.expiresAt.toISOString(),
|
||||
scopes: m365?.scopes ?? [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* M365-Integration trennen.
|
||||
*/
|
||||
async removeM365Integration(userId: string): Promise<void> {
|
||||
await this.prisma.userIntegration.deleteMany({
|
||||
where: { userId, provider: 'MICROSOFT_365' },
|
||||
});
|
||||
this.logger.log(`M365-Integration entfernt fuer User ${userId}`);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue