fix and update
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
99
apps/sso-core/src/domain/fedcm.service.ts
Normal file
99
apps/sso-core/src/domain/fedcm.service.ts
Normal 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-кода');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))];
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user