fix and update
This commit is contained in:
@@ -14,6 +14,7 @@ import { ProfileController } from './controllers/profile.controller';
|
||||
import { DocumentsController } from './controllers/documents.controller';
|
||||
import { AddressesController } from './controllers/addresses.controller';
|
||||
import { OAuthController } from './controllers/oauth.controller';
|
||||
import { FedcmController } from './controllers/fedcm.controller';
|
||||
import { WellKnownController, OAuthAuthorizeDiscoveryController } from './controllers/well-known.controller';
|
||||
import { AdvancedAuthController } from './controllers/advanced-auth.controller';
|
||||
import { FamilyController } from './controllers/family.controller';
|
||||
@@ -25,6 +26,7 @@ import { AdminBotController } from './controllers/admin-bot.controller';
|
||||
import { TelegramBotApiController } from './controllers/telegram-bot-api.controller';
|
||||
import { CoreGrpcService } from './core-grpc.service';
|
||||
import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.guard';
|
||||
import { FedcmCookieInterceptor } from './interceptors/fedcm-cookie.interceptor';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -59,7 +61,7 @@ import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.gua
|
||||
}
|
||||
])
|
||||
],
|
||||
controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, WellKnownController, OAuthAuthorizeDiscoveryController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController, BotController, AdminBotController, TelegramBotApiController],
|
||||
providers: [CoreGrpcService, AdminGuard, RbacManageGuard, SuperAdminGuard]
|
||||
controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, FedcmController, WellKnownController, OAuthAuthorizeDiscoveryController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController, BotController, AdminBotController, TelegramBotApiController],
|
||||
providers: [CoreGrpcService, AdminGuard, RbacManageGuard, SuperAdminGuard, FedcmCookieInterceptor]
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Body, Controller, Get, Headers, Ip, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Headers, Ip, Post, UseInterceptors } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { BeginTotpLoginDto, IdentifyDto, LdapLoginDto, LoginDto, PasswordlessOtpDto, PasswordlessVerifyDto, PasswordLoginDto, RefreshSessionDto, RegisterDto, VerifyPinDto, VerifyTotpLoginDto } from '../dto/auth.dto';
|
||||
import { resolveAuthorizedPayload, verifyAccessToken } from '../session-auth';
|
||||
import { FedcmCookieInterceptor } from '../interceptors/fedcm-cookie.interceptor';
|
||||
|
||||
@ApiTags('Аутентификация')
|
||||
@Controller('auth')
|
||||
@UseInterceptors(FedcmCookieInterceptor)
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly core: CoreGrpcService,
|
||||
|
||||
192
apps/api-gateway/src/controllers/fedcm.controller.ts
Normal file
192
apps/api-gateway/src/controllers/fedcm.controller.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
Options,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UnauthorizedException
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { Request, Response } from 'express';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { applyFedcmCorsHeaders, applyFedcmPreflightHeaders } from '../lib/fedcm-cors';
|
||||
import { resolveFedcmSessionFromRequest, setFedcmSessionCookie } from '../lib/fedcm-cookie';
|
||||
import { resolveFrontendUrl, resolveOAuthIssuer } from '../lib/oauth-issuer';
|
||||
import { assertSessionUnlocked, verifyAccessToken } from '../session-auth';
|
||||
|
||||
type FedcmAccountsResponse = {
|
||||
accounts: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
given_name?: string;
|
||||
email?: string;
|
||||
picture?: string;
|
||||
approved_clients?: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
@ApiTags('FedCM')
|
||||
@Controller('fedcm')
|
||||
export class FedcmController {
|
||||
constructor(
|
||||
private readonly core: CoreGrpcService,
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
@Options('accounts')
|
||||
@HttpCode(204)
|
||||
accountsPreflight(@Headers('origin') origin: string | undefined, @Res({ passthrough: true }) res: Response) {
|
||||
applyFedcmPreflightHeaders(res, origin);
|
||||
}
|
||||
|
||||
@Options('id_assertion')
|
||||
@HttpCode(204)
|
||||
idAssertionPreflight(@Headers('origin') origin: string | undefined, @Res({ passthrough: true }) res: Response) {
|
||||
applyFedcmPreflightHeaders(res, origin);
|
||||
}
|
||||
|
||||
@Options('client_metadata')
|
||||
@HttpCode(204)
|
||||
clientMetadataPreflight(@Headers('origin') origin: string | undefined, @Res({ passthrough: true }) res: Response) {
|
||||
applyFedcmPreflightHeaders(res, origin);
|
||||
}
|
||||
|
||||
@Get('config.json')
|
||||
@ApiOperation({ summary: 'FedCM provider config', description: 'Конфигурация Identity Provider для Federated Credential Management API.' })
|
||||
async config(@Res({ passthrough: true }) res: Response) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||
|
||||
const issuer = await resolveOAuthIssuer(this.core);
|
||||
const frontendUrl = await resolveFrontendUrl(this.core);
|
||||
let projectName = 'MVK ID';
|
||||
|
||||
try {
|
||||
const setting = (await firstValueFrom(this.core.settings.GetSetting({ key: 'PROJECT_NAME' }))) as { value?: string };
|
||||
if (setting.value?.trim()) {
|
||||
projectName = setting.value.trim();
|
||||
}
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
|
||||
return {
|
||||
accounts_endpoint: `${issuer}/fedcm/accounts`,
|
||||
client_metadata_endpoint: `${issuer}/fedcm/client_metadata`,
|
||||
id_assertion_endpoint: `${issuer}/fedcm/id_assertion`,
|
||||
login_url: `${frontendUrl}/auth/login`,
|
||||
signup_url: `${frontendUrl}/auth/register`,
|
||||
branding: {
|
||||
background_color: '#ffffff',
|
||||
color: '#3390ec',
|
||||
icons: [{ url: `${frontendUrl}/favicon.ico`, size: 32 }]
|
||||
},
|
||||
accounts: {
|
||||
include_anonymous: false
|
||||
},
|
||||
fields: ['name', 'email', 'picture'],
|
||||
display: projectName
|
||||
};
|
||||
}
|
||||
|
||||
@Get('accounts')
|
||||
@ApiOperation({ summary: 'FedCM accounts', description: 'Возвращает аккаунты пользователя по FedCM-сессии (cookie).' })
|
||||
async accounts(
|
||||
@Req() req: Request,
|
||||
@Headers('origin') origin: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
): Promise<FedcmAccountsResponse> {
|
||||
applyFedcmCorsHeaders(res, origin);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
const session = await resolveFedcmSessionFromRequest(this.jwt, req.headers.cookie);
|
||||
if (!session) {
|
||||
return { accounts: [] };
|
||||
}
|
||||
|
||||
const result = (await firstValueFrom(
|
||||
this.core.fedcm.GetAccounts({ userId: session.sub, sessionId: session.sessionId })
|
||||
)) as FedcmAccountsResponse;
|
||||
|
||||
return result ?? { accounts: [] };
|
||||
}
|
||||
|
||||
@Get('client_metadata')
|
||||
@ApiOperation({ summary: 'FedCM client metadata', description: 'Метаданные клиента для UI FedCM.' })
|
||||
async clientMetadata(
|
||||
@Query('client_id') clientId: string | undefined,
|
||||
@Headers('origin') origin: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
) {
|
||||
applyFedcmCorsHeaders(res, origin);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
if (!clientId?.trim()) {
|
||||
throw new UnauthorizedException('Передайте client_id');
|
||||
}
|
||||
|
||||
return firstValueFrom(this.core.fedcm.GetClientMetadata({ clientId: clientId.trim() }));
|
||||
}
|
||||
|
||||
@Post('id_assertion')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'FedCM id assertion', description: 'Выдаёт OIDC id_token для выбранного аккаунта и OAuth-клиента.' })
|
||||
async idAssertion(
|
||||
@Req() req: Request,
|
||||
@Headers('origin') origin: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
) {
|
||||
applyFedcmCorsHeaders(res, origin);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
const session = await resolveFedcmSessionFromRequest(this.jwt, req.headers.cookie);
|
||||
if (!session) {
|
||||
throw new UnauthorizedException('Сессия FedCM не найдена');
|
||||
}
|
||||
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const clientId = String(body.client_id ?? body.clientId ?? '').trim();
|
||||
const accountId = String(body.account_id ?? body.accountId ?? '').trim();
|
||||
if (!clientId || !accountId) {
|
||||
throw new UnauthorizedException('Передайте client_id и account_id');
|
||||
}
|
||||
|
||||
return firstValueFrom(
|
||||
this.core.fedcm.IssueIdAssertion({
|
||||
userId: session.sub,
|
||||
sessionId: session.sessionId,
|
||||
clientId,
|
||||
accountId
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Post('session/sync')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({
|
||||
summary: 'Синхронизировать FedCM cookie',
|
||||
description: 'Устанавливает HttpOnly cookie для FedCM по Bearer access token (для уже авторизованных пользователей IdP).'
|
||||
})
|
||||
async syncSession(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
) {
|
||||
const payload = await verifyAccessToken(this.jwt, authorization);
|
||||
if (!payload.sessionId) {
|
||||
throw new UnauthorizedException('Сессия не найдена');
|
||||
}
|
||||
await assertSessionUnlocked(this.core, payload);
|
||||
await setFedcmSessionCookie(res, this.jwt, {
|
||||
sub: payload.sub,
|
||||
sessionId: payload.sessionId,
|
||||
pinVerified: true
|
||||
});
|
||||
return { synced: true };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Controller, Get, Res } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { buildOpenIdConfiguration, resolveOAuthIssuer } from '../lib/oauth-issuer';
|
||||
|
||||
@@ -8,6 +10,20 @@ import { buildOpenIdConfiguration, resolveOAuthIssuer } from '../lib/oauth-issue
|
||||
export class WellKnownController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@Get('web-identity')
|
||||
@ApiOperation({
|
||||
summary: 'FedCM web identity manifest',
|
||||
description: 'Манифест Federated Credential Management API, указывающий на конфигурацию провайдера.'
|
||||
})
|
||||
async webIdentity(@Res({ passthrough: true }) res: Response) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||
const issuer = await resolveOAuthIssuer(this.core);
|
||||
return {
|
||||
provider_urls: [`${issuer}/fedcm/config.json`]
|
||||
};
|
||||
}
|
||||
|
||||
@Get('openid-configuration')
|
||||
@ApiOperation({
|
||||
summary: 'OpenID Connect Discovery',
|
||||
|
||||
@@ -15,6 +15,7 @@ export class CoreGrpcService implements OnModuleInit {
|
||||
documents!: Record<string, GrpcMethod>;
|
||||
addresses!: Record<string, GrpcMethod>;
|
||||
oauth!: Record<string, GrpcMethod>;
|
||||
fedcm!: Record<string, GrpcMethod>;
|
||||
otp!: Record<string, GrpcMethod>;
|
||||
advancedAuth!: Record<string, GrpcMethod>;
|
||||
notifications!: Record<string, GrpcMethod>;
|
||||
@@ -35,6 +36,7 @@ export class CoreGrpcService implements OnModuleInit {
|
||||
this.documents = this.client.getService<Record<string, GrpcMethod>>('DocumentsService');
|
||||
this.addresses = this.client.getService<Record<string, GrpcMethod>>('AddressesService');
|
||||
this.oauth = this.client.getService<Record<string, GrpcMethod>>('OAuthCoreService');
|
||||
this.fedcm = this.client.getService<Record<string, GrpcMethod>>('FedcmService');
|
||||
this.otp = this.client.getService<Record<string, GrpcMethod>>('OtpService');
|
||||
this.advancedAuth = this.client.getService<Record<string, GrpcMethod>>('AdvancedAuthService');
|
||||
this.family = this.client.getService<Record<string, GrpcMethod>>('FamilyService');
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import type { Response } from 'express';
|
||||
import { attachFedcmCookieFromAuthResult } from '../lib/fedcm-cookie';
|
||||
|
||||
@Injectable()
|
||||
export class FedcmCookieInterceptor implements NestInterceptor {
|
||||
constructor(private readonly jwt: JwtService) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const http = context.switchToHttp();
|
||||
const response = http.getResponse<Response>();
|
||||
|
||||
return next.handle().pipe(
|
||||
tap((data) => {
|
||||
attachFedcmCookieFromAuthResult(response, this.jwt, data);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
128
apps/api-gateway/src/lib/fedcm-cookie.ts
Normal file
128
apps/api-gateway/src/lib/fedcm-cookie.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { Response } from 'express';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
export const FEDCM_SESSION_COOKIE = 'lendry_fedcm_sess';
|
||||
|
||||
export interface FedcmSessionPayload {
|
||||
sub: string;
|
||||
sessionId: string;
|
||||
pinVerified: boolean;
|
||||
}
|
||||
|
||||
function cookieSecureEnabled() {
|
||||
if (process.env.FEDCM_COOKIE_SECURE === 'true') return true;
|
||||
if (process.env.FEDCM_COOKIE_SECURE === 'false') return false;
|
||||
return process.env.NODE_ENV === 'production';
|
||||
}
|
||||
|
||||
function cookieMaxAgeSeconds() {
|
||||
const parsed = Number(process.env.FEDCM_COOKIE_MAX_AGE ?? 2_592_000);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 2_592_000;
|
||||
}
|
||||
|
||||
export async function signFedcmSessionPayload(jwt: JwtService, payload: FedcmSessionPayload) {
|
||||
return jwt.signAsync(
|
||||
{ sub: payload.sub, sessionId: payload.sessionId, pinVerified: payload.pinVerified, typ: 'fedcm_session' },
|
||||
{
|
||||
secret: process.env.JWT_ACCESS_SECRET ?? 'docker-access-secret',
|
||||
expiresIn: cookieMaxAgeSeconds(),
|
||||
issuer: 'id.lendry.ru'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function verifyFedcmSessionToken(jwt: JwtService, token: string): Promise<FedcmSessionPayload | null> {
|
||||
try {
|
||||
const payload = await jwt.verifyAsync<{ sub: string; sessionId: string; pinVerified?: boolean; typ?: string }>(token, {
|
||||
secret: process.env.JWT_ACCESS_SECRET ?? 'docker-access-secret',
|
||||
issuer: 'id.lendry.ru'
|
||||
});
|
||||
if (payload.typ !== 'fedcm_session' || !payload.sub || !payload.sessionId) {
|
||||
return null;
|
||||
}
|
||||
if (payload.pinVerified === false) {
|
||||
return null;
|
||||
}
|
||||
return { sub: payload.sub, sessionId: payload.sessionId, pinVerified: true };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setFedcmSessionCookie(res: Response, jwt: JwtService, payload: FedcmSessionPayload) {
|
||||
if (!payload.sub || !payload.sessionId || payload.pinVerified === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const secure = cookieSecureEnabled();
|
||||
const value = await signFedcmSessionPayload(jwt, payload);
|
||||
|
||||
res.cookie(FEDCM_SESSION_COOKIE, value, {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: secure ? 'none' : 'lax',
|
||||
path: '/',
|
||||
maxAge: cookieMaxAgeSeconds() * 1000
|
||||
});
|
||||
}
|
||||
|
||||
export function clearFedcmSessionCookie(res: Response) {
|
||||
const secure = cookieSecureEnabled();
|
||||
res.clearCookie(FEDCM_SESSION_COOKIE, {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: secure ? 'none' : 'lax',
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
export function readFedcmSessionCookie(cookieHeader?: string): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
const parts = cookieHeader.split(';');
|
||||
for (const part of parts) {
|
||||
const [rawKey, ...rawValue] = part.trim().split('=');
|
||||
if (rawKey === FEDCM_SESSION_COOKIE) {
|
||||
const value = rawValue.join('=').trim();
|
||||
return value || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveFedcmSessionFromRequest(
|
||||
jwt: JwtService,
|
||||
cookieHeader?: string
|
||||
): Promise<FedcmSessionPayload | null> {
|
||||
const token = readFedcmSessionCookie(cookieHeader);
|
||||
if (!token) return null;
|
||||
return verifyFedcmSessionToken(jwt, token);
|
||||
}
|
||||
|
||||
export function attachFedcmCookieFromAuthResult(
|
||||
res: Response,
|
||||
jwt: JwtService,
|
||||
result: unknown
|
||||
): void {
|
||||
if (!result || typeof result !== 'object') return;
|
||||
const data = result as {
|
||||
accessToken?: string;
|
||||
sessionId?: string;
|
||||
pinVerified?: boolean;
|
||||
requiresPin?: boolean;
|
||||
user?: { id?: string };
|
||||
};
|
||||
|
||||
if (data.requiresPin || data.pinVerified === false || !data.sessionId) {
|
||||
clearFedcmSessionCookie(res);
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = data.user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
void setFedcmSessionCookie(res, jwt, {
|
||||
sub: userId,
|
||||
sessionId: data.sessionId,
|
||||
pinVerified: true
|
||||
});
|
||||
}
|
||||
16
apps/api-gateway/src/lib/fedcm-cors.ts
Normal file
16
apps/api-gateway/src/lib/fedcm-cors.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Response } from 'express';
|
||||
|
||||
export function applyFedcmCorsHeaders(res: Response, origin?: string) {
|
||||
if (origin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
res.setHeader('Vary', 'Origin');
|
||||
}
|
||||
}
|
||||
|
||||
export function applyFedcmPreflightHeaders(res: Response, origin?: string) {
|
||||
applyFedcmCorsHeaders(res, origin);
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site');
|
||||
res.setHeader('Access-Control-Max-Age', '86400');
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { AppModule } from './app.module';
|
||||
import { AllExceptionsFilter } from './grpc-exception.filter';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.use(cookieParser());
|
||||
app.enableCors({ origin: true, credentials: true });
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
|
||||
Reference in New Issue
Block a user