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[]
|
tenantMemberships TenantMembership[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
expertProfile ExpertProfile?
|
expertProfile ExpertProfile?
|
||||||
|
integrations UserIntegration[]
|
||||||
|
|
||||||
@@map("users")
|
@@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
|
// 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 { TenantsModule } from './core/tenants/tenants.module';
|
||||||
import { ExpertProfileModule } from './core/expert-profile/expert-profile.module';
|
import { ExpertProfileModule } from './core/expert-profile/expert-profile.module';
|
||||||
import { SettingsModule } from './core/settings/settings.module';
|
import { SettingsModule } from './core/settings/settings.module';
|
||||||
|
import { IntegrationsModule } from './core/integrations/integrations.module';
|
||||||
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||||
import { validateConfig } from './config/env.validation';
|
import { validateConfig } from './config/env.validation';
|
||||||
|
|
||||||
|
|
@ -44,6 +45,7 @@ import { validateConfig } from './config/env.validation';
|
||||||
TenantsModule,
|
TenantsModule,
|
||||||
ExpertProfileModule,
|
ExpertProfileModule,
|
||||||
SettingsModule,
|
SettingsModule,
|
||||||
|
IntegrationsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global Guards: Alle Routen sind standardmaessig geschuetzt
|
// Global Guards: Alle Routen sind standardmaessig geschuetzt
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,17 @@ class EnvironmentVariables {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
AZURE_REDIRECT_URI?: string;
|
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(
|
export function validateConfig(
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,16 @@ import {
|
||||||
} from '@azure/msal-node';
|
} from '@azure/msal-node';
|
||||||
import { RedisService } from '../../../redis/redis.service';
|
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.
|
* Informationen aus dem Microsoft ID-Token.
|
||||||
*/
|
*/
|
||||||
|
|
@ -55,7 +65,14 @@ export class EntraIdService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(EntraIdService.name);
|
private readonly logger = new Logger(EntraIdService.name);
|
||||||
private msalClient: ConfidentialClientApplication | null = null;
|
private msalClient: ConfidentialClientApplication | null = null;
|
||||||
private redirectUri = '';
|
private redirectUri = '';
|
||||||
|
private integrationRedirectUri = '';
|
||||||
private readonly scopes = ['openid', 'profile', 'email', 'User.Read'];
|
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(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
|
|
@ -123,6 +140,11 @@ export class EntraIdService implements OnModuleInit {
|
||||||
|
|
||||||
this.msalClient = new ConfidentialClientApplication(msalConfig);
|
this.msalClient = new ConfidentialClientApplication(msalConfig);
|
||||||
this.redirectUri = ssoConfig.redirectUri;
|
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,
|
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