fix and update

This commit is contained in:
lendry
2026-06-29 12:17:25 +03:00
parent 923a028cdd
commit 75ccbe5fc4
39 changed files with 1619 additions and 63 deletions

View File

@@ -20,6 +20,7 @@ import { ProfileService } from './domain/profile.service';
import { DocumentsService } from './domain/documents.service';
import { AddressesService } from './domain/addresses.service';
import { OAuthCoreService } from './domain/oauth-core.service';
import { FedcmService } from './domain/fedcm.service';
import { OAuthIssuerService } from './domain/oauth-issuer.service';
import { OtpService } from './domain/otp.service';
import { AdvancedAuthService } from './domain/advanced-auth.service';
@@ -74,6 +75,7 @@ import { BotFatherAssistantService } from './domain/bot/bot-father-assistant.ser
DocumentsService,
AddressesService,
OAuthCoreService,
FedcmService,
OAuthIssuerService,
OtpService,
AdvancedAuthService,

View File

@@ -14,6 +14,7 @@ import { ProfileService } from './profile.service';
import { DocumentsService } from './documents.service';
import { AddressesService } from './addresses.service';
import { OAuthCoreService } from './oauth-core.service';
import { FedcmService } from './fedcm.service';
import { OtpService } from './otp.service';
import { AdvancedAuthService } from './advanced-auth.service';
import { FamilyService } from './family.service';
@@ -42,6 +43,7 @@ export class AuthGrpcController {
private readonly documents: DocumentsService,
private readonly addresses: AddressesService,
private readonly oauthCore: OAuthCoreService,
private readonly fedcm: FedcmService,
private readonly otp: OtpService,
private readonly advancedAuth: AdvancedAuthService,
private readonly family: FamilyService,
@@ -831,6 +833,21 @@ export class AuthGrpcController {
return this.oauthCore.getClientPublicInfo(command.clientId);
}
@GrpcMethod('FedcmService', 'GetAccounts')
fedcmGetAccounts(command: { userId: string; sessionId: string }) {
return this.fedcm.getAccounts(command.userId, command.sessionId);
}
@GrpcMethod('FedcmService', 'IssueIdAssertion')
fedcmIssueIdAssertion(command: { userId: string; sessionId: string; clientId: string; accountId: string }) {
return this.fedcm.issueIdAssertion(command.userId, command.sessionId, command.clientId, command.accountId);
}
@GrpcMethod('FedcmService', 'GetClientMetadata')
fedcmGetClientMetadata(command: { clientId: string }) {
return this.fedcm.getClientMetadata(command.clientId);
}
@GrpcMethod('OtpService', 'SendOtp')
sendOtp(command: { target: string; channel: string; purpose: string; userId?: string }) {
return this.otp.send(command);

View File

@@ -0,0 +1,99 @@
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../infra/prisma.service';
import { OAuthCoreService } from './oauth-core.service';
import { SessionService } from './session.service';
import { assertHumanAccount, HUMAN_USER_WHERE } from './system-account.util';
@Injectable()
export class FedcmService {
constructor(
private readonly prisma: PrismaService,
private readonly session: SessionService,
private readonly oauthCore: OAuthCoreService
) {}
async getAccounts(userId: string, sessionId: string) {
await this.assertUnlockedSession(userId, sessionId);
const user = await this.prisma.user.findFirst({
where: { id: userId, deletedAt: null, status: 'ACTIVE', ...HUMAN_USER_WHERE }
});
if (!user) {
return { accounts: [] };
}
assertHumanAccount(user, { asAuth: true });
const consents = await this.prisma.oAuthConsent.findMany({
where: { userId },
include: { client: { select: { clientId: true, isActive: true } } }
});
const approvedClients = consents
.filter((item) => item.client.isActive)
.map((item) => item.client.clientId);
const givenName = user.displayName.trim().split(/\s+/)[0] || user.displayName;
return {
accounts: [
{
id: user.id,
name: user.displayName,
given_name: givenName,
email: user.email ?? undefined,
picture: user.avatarUrl ?? undefined,
approved_clients: approvedClients
}
]
};
}
async issueIdAssertion(userId: string, sessionId: string, clientId: string, accountId: string) {
if (accountId !== userId) {
throw new BadRequestException('account_id не совпадает с активной сессией');
}
await this.assertUnlockedSession(userId, sessionId);
const client = await this.prisma.oAuthClient.findUnique({
where: { clientId },
include: { scopes: { include: { scope: true } } }
});
if (!client?.isActive) {
throw new BadRequestException('OAuth-приложение не найдено или отключено');
}
const scopeSlugs = client.scopes.map((item) => item.scope.slug);
const scope = scopeSlugs.length > 0 ? scopeSlugs.join(' ') : 'openid profile email';
const consent = await this.oauthCore.checkConsent(userId, clientId, scope);
if (!consent.granted) {
await this.oauthCore.grantConsent(userId, clientId, scope);
}
const tokens = await this.oauthCore.issueFedcmTokens(userId, clientId, scopeSlugs.length > 0 ? scopeSlugs : ['openid', 'profile', 'email']);
return { token: tokens.idToken };
}
async getClientMetadata(clientId: string) {
const client = await this.prisma.oAuthClient.findUnique({ where: { clientId } });
if (!client?.isActive) {
throw new BadRequestException('OAuth-приложение не найдено или отключено');
}
return {
privacy_policy_url: 'https://id.lendry.ru/data',
terms_of_service_url: 'https://id.lendry.ru/data'
};
}
private async assertUnlockedSession(userId: string, sessionId: string) {
const state = await this.session.resolveSessionState(sessionId);
if (!state || state.userId !== userId) {
throw new UnauthorizedException('Сессия недействительна или истекла');
}
if (state.requiresPin) {
throw new UnauthorizedException('Требуется подтверждение PIN-кода');
}
}
}

View File

@@ -316,6 +316,10 @@ export class OAuthCoreService {
return { clientId: client.clientId, name: client.name };
}
async issueFedcmTokens(userId: string, clientId: string, scopes: string[]) {
return this.issueOAuthTokens(userId, clientId, scopes);
}
private parseScopeSlugs(scope: string) {
return [...new Set(scope.split(/\s+/).map((item) => item.trim()).filter(Boolean))];
}

View File

@@ -5,8 +5,7 @@ const BLOCKED_CHAT_MEDIA_TYPES = new Set([
'text/html',
'application/xhtml+xml',
'application/x-httpd-php',
'application/x-sh',
'application/x-msdownload'
'application/x-sh'
]);
const EXTENSION_TO_MIME: Record<string, string> = {
@@ -40,7 +39,8 @@ const EXTENSION_TO_MIME: Record<string, string> = {
rar: 'application/vnd.rar',
'7z': 'application/x-7z-compressed',
json: 'application/json',
xml: 'application/xml'
xml: 'application/xml',
apk: 'application/vnd.android.package-archive'
};
const MIME_ALIASES: Record<string, string> = {
@@ -53,7 +53,8 @@ const MIME_ALIASES: Record<string, string> = {
'image/jpg': 'image/jpeg',
'image/pjpeg': 'image/jpeg',
'application/x-pdf': 'application/pdf',
'application/x-zip-compressed': 'application/zip'
'application/x-zip-compressed': 'application/zip',
'application/vnd.android.package-archive': 'application/vnd.android.package-archive'
};
export type ChatAttachmentKind = 'IMAGE' | 'AUDIO' | 'VOICE' | 'FILE';
@@ -75,38 +76,29 @@ export function normalizeChatContentType(contentType?: string | null, fileName?:
return aliased || 'application/octet-stream';
}
function isBlockedDownloadType(normalized: string, fileName?: string | null) {
if (!BLOCKED_CHAT_MEDIA_TYPES.has(normalized)) {
return false;
}
const extension = extractFileExtension(fileName);
if (normalized === 'application/x-msdownload' && extension === 'apk') {
return false;
}
return true;
}
export function assertChatMediaContentType(contentType?: string | null, fileName?: string | null) {
const normalized = normalizeChatContentType(contentType, fileName);
if (BLOCKED_CHAT_MEDIA_TYPES.has(normalized)) {
if (isBlockedDownloadType(normalized, fileName)) {
throw new Error('Этот тип файла нельзя отправить в чат');
}
const [category] = normalized.split('/');
if (category === 'image' || category === 'audio' || category === 'video') {
if (category === 'image' || category === 'audio' || category === 'video' || category === 'text' || category === 'application') {
return normalized;
}
const allowedApplicationTypes = new Set([
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/zip',
'application/vnd.rar',
'application/x-7z-compressed',
'application/json',
'application/xml',
'application/octet-stream'
]);
if (category === 'text' || allowedApplicationTypes.has(normalized)) {
return normalized;
}
throw new Error('Неподдерживаемый тип медиафайла для чата');
return 'application/octet-stream';
}
export function extensionForChatContentType(contentType: string, fileName?: string | null) {
@@ -156,6 +148,8 @@ export function extensionForChatContentType(contentType: string, fileName?: stri
return 'ppt';
case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
return 'pptx';
case 'application/vnd.android.package-archive':
return 'apk';
case 'text/plain':
return 'txt';
case 'text/csv':