more fix and update

This commit is contained in:
lendry
2026-06-24 20:15:19 +03:00
parent dcab6557d3
commit 9727cf3f35
53 changed files with 3479 additions and 494 deletions

View File

@@ -29,6 +29,7 @@
"bcryptjs": "^3.0.3",
"ioredis": "^5.8.2",
"nodemailer": "^7.0.6",
"otplib": "^12.0.1",
"pg": "^8.22.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",

View File

@@ -73,6 +73,17 @@ model User {
chatRoomMembers ChatRoomMember[]
chatMessages ChatMessage[]
chatPollVotes ChatPollVote[]
totpSecret UserTotpSecret?
}
model UserTotpSecret {
id String @id @default(uuid())
userId String @unique
secretEnc String
isEnabled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model UserProfile {

View File

@@ -30,6 +30,7 @@ import { NotificationPublisherService } from './infra/notification-publisher.ser
import { MinioService } from './infra/minio.service';
import { LdapClientService } from './infra/ldap-client.service';
import { MessagingService } from './infra/messaging.service';
import { TotpService } from './domain/totp.service';
@Module({
imports: [
@@ -57,6 +58,7 @@ import { MessagingService } from './infra/messaging.service';
OAuthCoreService,
OtpService,
AdvancedAuthService,
TotpService,
FamilyService,
MediaService,
NotificationsService,

View File

@@ -1,8 +1,48 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomBytes, randomUUID } from 'node:crypto';
import { RedisService } from '../infra/redis.service';
import { AuthService } from './auth.service';
const QR_TTL_SECONDS = 120;
interface StoredQrSession {
status: 'PENDING' | 'APPROVED' | 'EXPIRED';
deviceName: string;
fingerprint: string;
deviceType?: string;
ipAddress?: string;
userAgent?: string;
auth?: {
accessToken: string;
refreshToken: string;
sessionId: string;
pinVerified: boolean;
expiresAt: string;
requiresTotp?: boolean;
totpChallengeToken?: string;
user: Awaited<ReturnType<AuthService['toPublicUser']>>;
};
expiresAt: string;
}
interface CreateQrSessionInput {
deviceName: string;
fingerprint: string;
deviceType?: string;
ipAddress?: string;
userAgent?: string;
}
@Injectable()
export class AdvancedAuthService {
constructor(
private readonly redis: RedisService,
private readonly config: ConfigService,
private readonly auth: AuthService
) {}
createWebAuthnRegistrationChallenge(userId: string) {
return this.challenge(`webauthn-register:${userId}`);
}
@@ -11,19 +51,111 @@ export class AdvancedAuthService {
return this.challenge(`webauthn-login:${userId}`);
}
createQrSession() {
return {
sessionId: randomUUID(),
status: 'PENDING',
expiresAt: new Date(Date.now() + 2 * 60_000).toISOString()
};
}
async createQrSession(input: CreateQrSessionInput) {
const sessionId = randomUUID();
const expiresAt = new Date(Date.now() + QR_TTL_SECONDS * 1000);
const apiBase = this.config.get<string>('PUBLIC_API_URL') ?? 'http://localhost:3001';
const qrPayload = JSON.stringify({
v: 1,
type: 'idp_qr_login',
sessionId,
apiBase,
expiresAt: expiresAt.toISOString()
});
const data: StoredQrSession = {
status: 'PENDING',
deviceName: input.deviceName,
fingerprint: input.fingerprint,
deviceType: input.deviceType,
ipAddress: input.ipAddress,
userAgent: input.userAgent,
expiresAt: expiresAt.toISOString()
};
await this.redis.client.set(`qr:session:${sessionId}`, JSON.stringify(data), 'EX', QR_TTL_SECONDS);
pollQrSession(sessionId: string) {
return {
sessionId,
status: 'PENDING',
expiresAt: new Date(Date.now() + 2 * 60_000).toISOString()
expiresAt: expiresAt.toISOString(),
qrPayload
};
}
async pollQrSession(sessionId: string) {
const raw = await this.redis.client.get(`qr:session:${sessionId}`);
if (!raw) {
return { sessionId, status: 'EXPIRED', expiresAt: new Date().toISOString() };
}
const data = JSON.parse(raw) as StoredQrSession;
if (data.status === 'PENDING' && new Date(data.expiresAt).getTime() <= Date.now()) {
return { sessionId, status: 'EXPIRED', expiresAt: data.expiresAt };
}
return {
sessionId,
status: data.status,
expiresAt: data.expiresAt,
qrPayload: undefined,
auth: data.auth
? {
accessToken: data.auth.accessToken,
refreshToken: data.auth.refreshToken,
sessionId: data.auth.sessionId,
pinVerified: data.auth.pinVerified,
expiresAt: data.auth.expiresAt,
requiresTotp: data.auth.requiresTotp,
totpChallengeToken: data.auth.totpChallengeToken,
user: data.auth.user
}
: undefined
};
}
async approveQrSession(sessionId: string, userId: string) {
const key = `qr:session:${sessionId}`;
const raw = await this.redis.client.get(key);
if (!raw) {
throw new NotFoundException('QR-сессия не найдена или истекла');
}
const data = JSON.parse(raw) as StoredQrSession;
if (data.status !== 'PENDING') {
throw new BadRequestException('QR-сессия уже обработана');
}
if (new Date(data.expiresAt).getTime() <= Date.now()) {
throw new BadRequestException('QR-сессия истекла');
}
const auth = await this.auth.createQrApprovedLogin(userId, {
fingerprint: data.fingerprint,
deviceName: data.deviceName,
deviceType: data.deviceType,
ipAddress: data.ipAddress,
userAgent: data.userAgent
});
data.status = 'APPROVED';
data.auth = auth;
const ttl = await this.redis.client.ttl(key);
await this.redis.client.set(key, JSON.stringify(data), 'EX', Math.max(ttl, 30));
return {
sessionId,
status: 'APPROVED',
expiresAt: data.expiresAt,
auth: {
accessToken: auth.accessToken,
refreshToken: auth.refreshToken,
sessionId: auth.sessionId,
pinVerified: auth.pinVerified,
expiresAt: auth.expiresAt,
requiresTotp: auth.requiresTotp,
totpChallengeToken: auth.totpChallengeToken,
user: auth.user
}
};
}

View File

@@ -20,6 +20,7 @@ import { FamilyService } from './family.service';
import { MediaService } from './media.service';
import { NotificationsService } from './notifications.service';
import { ChatService } from './chat.service';
import { TotpService } from './totp.service';
import { MessagingService } from '../infra/messaging.service';
@Controller()
@@ -43,7 +44,8 @@ export class AuthGrpcController {
private readonly media: MediaService,
private readonly notifications: NotificationsService,
private readonly chat: ChatService,
private readonly messaging: MessagingService
private readonly messaging: MessagingService,
private readonly totp: TotpService
) {}
@GrpcMethod('AuthService', 'Register')
@@ -62,8 +64,8 @@ export class AuthGrpcController {
}
@GrpcMethod('AuthService', 'SendOtp')
sendPasswordlessOtp(command: { recipient: string; channel?: string }) {
return this.auth.sendOtp(command.recipient, command.channel);
sendPasswordlessOtp(command: { recipient: string; channel?: string; ipAddress?: string }) {
return this.auth.sendOtp(command.recipient, command.channel, command.ipAddress);
}
@GrpcMethod('AuthService', 'VerifyOtp')
@@ -89,6 +91,23 @@ export class AuthGrpcController {
return this.auth.loginWithLdap(command);
}
@GrpcMethod('AuthService', 'VerifyTotpLogin')
verifyTotpLogin(command: { totpChallengeToken: string; code: string }) {
return this.auth.verifyTotpLogin(command.totpChallengeToken, command.code);
}
@GrpcMethod('AuthService', 'BeginTotpLogin')
beginTotpLogin(command: {
recipient: string;
fingerprint: string;
deviceName?: string;
deviceType?: string;
ipAddress?: string;
userAgent?: string;
}) {
return this.auth.beginTotpLogin(command);
}
@GrpcMethod('AuthService', 'VerifyPin')
verifyPin(command: VerifyPinCommand) {
return this.pin.verifyPin(command.sessionId, command.pin);
@@ -283,8 +302,8 @@ export class AuthGrpcController {
}
@GrpcMethod('SecurityService', 'ListActiveDevices')
async listActiveDevices(command: { userId: string }) {
const devices = await this.security.listActiveDevices(command.userId);
async listActiveDevices(command: { userId: string; exceptSessionId?: string }) {
const devices = await this.security.listActiveDevices(command.userId, command.exceptSessionId);
return {
devices: devices.map((device) => ({
id: device.id,
@@ -337,11 +356,31 @@ export class AuthGrpcController {
}
@GrpcMethod('SecurityService', 'RevokeAllSessions')
async revokeAllSessions(command: { userId: string }) {
const result = await this.security.revokeAllSessions(command.userId);
async revokeAllSessions(command: { userId: string; exceptSessionId?: string }) {
const result = await this.security.revokeAllSessions(command.userId, command.exceptSessionId);
return { count: result.count };
}
@GrpcMethod('SecurityService', 'GetTotpStatus')
getTotpStatus(command: { userId: string }) {
return this.totp.getStatus(command.userId);
}
@GrpcMethod('SecurityService', 'SetupTotp')
setupTotp(command: { userId: string }) {
return this.totp.setup(command.userId);
}
@GrpcMethod('SecurityService', 'EnableTotp')
enableTotp(command: { userId: string; code: string }) {
return this.totp.enable(command.userId, command.code);
}
@GrpcMethod('SecurityService', 'DisableTotp')
disableTotp(command: { userId: string; code: string }) {
return this.totp.disable(command.userId, command.code);
}
@GrpcMethod('SecurityService', 'SetupPin')
setupPin(command: { userId: string; pin: string }) {
return this.pin.setupPin(command.userId, command.pin);
@@ -459,7 +498,7 @@ export class AuthGrpcController {
}
@GrpcMethod('ProfileService', 'UpdateProfile')
updateProfile(command: { userId: string; firstName?: string; lastName?: string; bio?: string; age?: number; gender?: string }) {
updateProfile(command: { userId: string; firstName?: string; lastName?: string; bio?: string; age?: number; gender?: string; birthDate?: string }) {
return this.profile.updateProfile(command);
}
@@ -621,8 +660,14 @@ export class AuthGrpcController {
}
@GrpcMethod('AdvancedAuthService', 'CreateQrSession')
createQrSession(command: { deviceName: string }) {
return this.advancedAuth.createQrSession();
createQrSession(command: { deviceName: string; fingerprint?: string; deviceType?: string; ipAddress?: string; userAgent?: string }) {
return this.advancedAuth.createQrSession({
deviceName: command.deviceName,
fingerprint: command.fingerprint ?? command.deviceName,
deviceType: command.deviceType,
ipAddress: command.ipAddress,
userAgent: command.userAgent
});
}
@GrpcMethod('AdvancedAuthService', 'PollQrSession')
@@ -630,6 +675,11 @@ export class AuthGrpcController {
return this.advancedAuth.pollQrSession(command.sessionId);
}
@GrpcMethod('AdvancedAuthService', 'ApproveQrSession')
approveQrSession(command: { sessionId: string; userId: string }) {
return this.advancedAuth.approveQrSession(command.sessionId, command.userId);
}
@GrpcMethod('FamilyService', 'CreateFamilyGroup')
createFamilyGroup(command: { ownerId: string; name: string }) {
return this.family.create(command.ownerId, command.name);
@@ -666,8 +716,16 @@ export class AuthGrpcController {
}
@GrpcMethod('FamilyService', 'SendFamilyInvite')
sendFamilyInvite(command: { requesterId: string; groupId: string; target: string }) {
return this.family.sendInvite(command.requesterId, command.groupId, command.target);
sendFamilyInvite(command: { requesterId: string; groupId: string; target?: string; inviteeUserId?: string }) {
return this.family.sendInvite(command.requesterId, command.groupId, {
target: command.target,
inviteeUserId: command.inviteeUserId
});
}
@GrpcMethod('FamilyService', 'SearchFamilyInviteUsers')
searchFamilyInviteUsers(command: { requesterId: string; groupId: string; query: string }) {
return this.family.searchInviteUsers(command.requesterId, command.groupId, command.query);
}
@GrpcMethod('FamilyService', 'RespondFamilyInvite')

View File

@@ -12,8 +12,14 @@ import { AuthTokens, LoginCommand, PublicUser, RegisterCommand } from './dto';
import { AccessService } from './access.service';
import { SettingsService } from './settings.service';
import { MessagingService } from '../infra/messaging.service';
import { NotificationsService } from './notifications.service';
import { canonicalPhoneE164, phoneLookupVariants } from '../infra/phone.util';
import { TotpService } from './totp.service';
const FIRST_ADMIN_LOCK = 'locks:first-super-admin';
const TOTP_LOGIN_CHALLENGE_TTL_SECONDS = 5 * 60;
type DeviceCommand = Pick<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress' | 'userAgent'>;
@Injectable()
export class AuthService {
@@ -25,7 +31,9 @@ export class AuthService {
private readonly access: AccessService,
private readonly settings: SettingsService,
private readonly ldapClient: LdapClientService,
private readonly messaging: MessagingService
private readonly messaging: MessagingService,
private readonly notifications: NotificationsService,
private readonly totp: TotpService
) {}
async register(command: RegisterCommand): Promise<PublicUser> {
@@ -95,28 +103,99 @@ export class AuthService {
const device = await this.upsertDevice(user.id, command);
const auth = await this.createAuthTokens(user, device.id, command);
await this.writeSignInEvent(user.id, true, command, device.id);
return this.finalizeAuthentication(user, command, command, device.id, { skipTotp: true });
}
async beginTotpLogin(command: DeviceCommand & { recipient: string }) {
const user = await this.findUserByRecipient(command.recipient);
if (!user || user.status !== UserStatus.ACTIVE) {
throw new UnauthorizedException('Пользователь не найден');
}
if (!(await this.totp.isEnabledForUser(user.id))) {
throw new BadRequestException('Для этого аккаунта не настроен аутентификатор');
}
const device = await this.upsertDevice(user.id, command);
const challenge = await this.issueTotpChallenge(
user,
command,
{ ...command, login: command.recipient, password: '' },
device.id
);
return { totpChallengeToken: challenge.totpChallengeToken };
}
async verifyTotpLogin(totpChallengeToken: string, code: string) {
let payload: { sub: string; typ: string; jti?: string };
try {
payload = await this.jwt.verifyAsync(totpChallengeToken, {
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
issuer: 'id.lendry.ru'
});
} catch {
throw new UnauthorizedException('Сессия входа истекла, повторите попытку');
}
if (payload.typ !== 'totp_login_challenge' || !payload.jti) {
throw new UnauthorizedException('Некорректный токен подтверждения');
}
const challengeKey = this.totpLoginChallengeKey(payload.jti);
const raw = await this.redis.client.get(challengeKey);
if (!raw) {
throw new UnauthorizedException('Сессия входа истекла, повторите попытку');
}
const challenge = JSON.parse(raw) as DeviceCommand & { userId: string; deviceId: string; signIn: LoginCommand };
if (challenge.userId !== payload.sub) {
throw new UnauthorizedException('Некорректный токен подтверждения');
}
const attemptKey = this.totpLoginAttemptKey(payload.jti);
const maxAttempts = await this.settings.getNumber('OTP_MAX_ATTEMPTS', 5);
const attempts = Number((await this.redis.client.get(attemptKey)) ?? 0);
if (attempts >= maxAttempts) {
throw new UnauthorizedException('Превышено число попыток ввода кода аутентификатора');
}
const isValid = await this.totp.verifyEnabledCode(payload.sub, code);
if (!isValid) {
await this.redis.client.set(attemptKey, String(attempts + 1), 'EX', TOTP_LOGIN_CHALLENGE_TTL_SECONDS);
throw new UnauthorizedException('Неверный код аутентификатора');
}
await this.redis.client.del(challengeKey);
await this.redis.client.del(attemptKey);
const user = await this.prisma.user.findFirst({
where: { id: payload.sub, deletedAt: null, status: UserStatus.ACTIVE },
include: { pinCode: true }
});
if (!user) {
throw new UnauthorizedException('Пользователь не найден или заблокирован');
}
const auth = await this.createAuthTokens(user, challenge.deviceId, challenge);
await this.writeSignInEvent(user.id, true, challenge.signIn, challenge.deviceId);
return auth;
}
async identify(login: string) {
const user = await this.findUserByRecipient(login);
const methods: Array<{ kind: string; channel: string; masked: string }> = [];
const otpChannels = user ? this.buildOtpChannels(user) : [];
if (user?.passwordHash) {
methods.push({ kind: 'password', channel: 'password', masked: 'Пароль' });
}
if (user) {
const channels: Array<{ channel: string; value: string | null }> = [
{ channel: 'email', value: user.email },
{ channel: 'phone', value: user.phone },
const backupChannels: Array<{ channel: string; value: string | null }> = [
{ channel: 'backupEmail', value: user.backupEmail },
{ channel: 'backupPhone', value: user.backupPhone }
];
for (const item of channels) {
if (item.value && item.value !== login) {
for (const item of backupChannels) {
if (item.value) {
methods.push({ kind: 'otp', channel: item.channel, masked: this.maskTarget(item.value) });
}
}
@@ -126,13 +205,15 @@ export class AuthService {
exists: Boolean(user),
hasPassword: Boolean(user?.passwordHash),
isPinEnabled: Boolean(user?.pinCode?.isEnabled),
isTotpEnabled: user ? await this.totp.isEnabledForUser(user.id) : false,
otpChannels,
methods
};
}
async sendOtp(recipient: string, channel?: string) {
async sendOtp(recipient: string, channel?: string, ipAddress?: string) {
const existingUser = await this.findUserByRecipient(recipient);
const target = this.resolveOtpTarget(recipient, channel, existingUser);
const target = this.normalizeOtpTarget(this.resolveOtpTarget(recipient, channel, existingUser));
const deliveryChannel = target.includes('@') ? 'email' : 'sms';
const expiryMinutes = await this.settings.getNumber('OTP_EXPIRY_MINUTES', 10);
const code = String(randomInt(100000, 999999));
@@ -153,18 +234,37 @@ export class AuthService {
code,
expiryMinutes
});
if (existingUser) {
await this.publishOtpPushNotification(existingUser.id, code, ipAddress, 'Вход в аккаунт');
}
await this.redis.client.del(this.otpAttemptKey(target));
return { sent: true, expiresAt: expiresAt.toISOString(), maskedTarget: this.maskTarget(target) };
}
private async publishOtpPushNotification(userId: string, code: string, ipAddress: string | undefined, action: string) {
const ipLabel = ipAddress?.trim() || 'неизвестного IP';
await this.notifications.create(
userId,
'otp_code',
action,
`Попытка входа с ${ipLabel} — код для верификации: ${code}`,
{ code, ipAddress: ipAddress ?? null, action },
{ skipPublish: false }
);
}
async verifyOtp(command: LoginCommand & { recipient: string; code: string }) {
const existingUser = await this.findUserByRecipient(command.recipient);
const targetVariants = this.recipientLookupTargets(command.recipient);
const authCode = await this.prisma.authCode.findFirst({
where: {
purpose: 'passwordless-login',
usedAt: null,
expiresAt: { gt: new Date() },
OR: [{ target: command.recipient }, ...(existingUser ? [{ userId: existingUser.id }] : [])]
OR: [
{ target: { in: targetVariants } },
...(existingUser ? [{ userId: existingUser.id }] : [])
]
},
orderBy: { createdAt: 'desc' }
});
@@ -178,7 +278,8 @@ export class AuthService {
throw new UnauthorizedException('Превышено число попыток ввода кода');
}
const matches = await bcrypt.compare(command.code, authCode.codeHash);
const normalizedCode = command.code.replace(/\s/g, '');
const matches = await bcrypt.compare(normalizedCode, authCode.codeHash);
if (!matches) {
await this.redis.client.set(attemptKey, String(attempts + 1), 'EX', 15 * 60);
throw new UnauthorizedException('Неверный код входа');
@@ -189,9 +290,28 @@ export class AuthService {
const user = await this.findOrCreatePasswordlessUser(command.recipient);
const device = await this.upsertDevice(user.id, command);
const auth = await this.createAuthTokens(user, device.id, command);
await this.writeSignInEvent(user.id, true, { ...command, login: command.recipient, password: '' }, device.id);
return { requiresPassword: false, tempAuthToken: undefined, auth };
const auth = await this.finalizeAuthentication(
user,
command,
{ ...command, login: command.recipient, password: '' },
device.id,
{ skipTotp: true }
);
return {
requiresPassword: false,
auth
};
}
private buildOtpChannels(user: { email: string | null; phone: string | null }) {
const channels: Array<{ channel: string; masked: string }> = [];
if (user.email) {
channels.push({ channel: 'email', masked: this.maskTarget(user.email) });
}
if (user.phone) {
channels.push({ channel: 'phone', masked: this.maskTarget(user.phone) });
}
return channels;
}
private resolveOtpTarget(recipient: string, channel: string | undefined, user: { email: string | null; phone: string | null; backupEmail: string | null; backupPhone: string | null } | null) {
@@ -231,9 +351,38 @@ export class AuthService {
throw new UnauthorizedException('Неверный пароль');
}
const device = await this.upsertDevice(user.id, command);
const auth = await this.createAuthTokens(user, device.id, command);
await this.writeSignInEvent(user.id, true, command, device.id);
return auth;
return this.finalizeAuthentication(user, command, command, device.id, { skipTotp: true });
}
async createQrApprovedLogin(userId: string, command: Pick<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress' | 'userAgent'>) {
const user = await this.prisma.user.findFirst({
where: { id: userId, deletedAt: null, status: UserStatus.ACTIVE },
include: { pinCode: true }
});
if (!user) {
throw new UnauthorizedException('Пользователь не найден или заблокирован');
}
const device = await this.upsertDevice(user.id, {
fingerprint: command.fingerprint,
deviceName: command.deviceName,
deviceType: command.deviceType,
ipAddress: command.ipAddress
});
return this.finalizeAuthentication(
user,
command,
{
fingerprint: command.fingerprint,
deviceName: command.deviceName,
deviceType: command.deviceType,
ipAddress: command.ipAddress,
userAgent: command.userAgent,
login: 'qr-login',
password: ''
},
device.id,
{ skipTotp: true }
);
}
async loginWithLdap(command: {
@@ -261,14 +410,21 @@ export class AuthService {
const user = await this.findOrCreateLdapUser(ldapResult);
const device = await this.upsertDevice(user.id, command);
const auth = await this.createAuthTokens(user, device.id, command);
await this.writeSignInEvent(
user.id,
true,
{ login: command.username, password: '', fingerprint: command.fingerprint, deviceName: command.deviceName, deviceType: command.deviceType, ipAddress: command.ipAddress, userAgent: command.userAgent },
device.id
return this.finalizeAuthentication(
user,
command,
{
login: command.username,
password: '',
fingerprint: command.fingerprint,
deviceName: command.deviceName,
deviceType: command.deviceType,
ipAddress: command.ipAddress,
userAgent: command.userAgent
},
device.id,
{ skipTotp: true }
);
return auth;
}
private async getLdapConfig(): Promise<LdapConnectionConfig> {
@@ -364,6 +520,66 @@ export class AuthService {
}
}
private async finalizeAuthentication(
user: User & { pinCode?: { isEnabled: boolean } | null },
deviceCommand: DeviceCommand,
signInCommand: LoginCommand,
deviceId: string,
options?: { skipTotp?: boolean }
): Promise<AuthTokens> {
if (!options?.skipTotp && (await this.totp.isEnabledForUser(user.id))) {
return this.issueTotpChallenge(user, deviceCommand, signInCommand, deviceId);
}
const auth = await this.createAuthTokens(user, deviceId, deviceCommand);
await this.writeSignInEvent(user.id, true, signInCommand, deviceId);
return auth;
}
private async issueTotpChallenge(
user: User & { pinCode?: { isEnabled: boolean } | null },
deviceCommand: DeviceCommand,
signInCommand: LoginCommand,
deviceId: string
): Promise<AuthTokens> {
const challengeId = randomBytes(16).toString('base64url');
const totpChallengeToken = await this.jwt.signAsync(
{ sub: user.id, typ: 'totp_login_challenge', jti: challengeId },
{ secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'), expiresIn: '5m', issuer: 'id.lendry.ru' }
);
await this.redis.client.set(
this.totpLoginChallengeKey(challengeId),
JSON.stringify({
userId: user.id,
deviceId,
signIn: signInCommand,
...deviceCommand
}),
'EX',
TOTP_LOGIN_CHALLENGE_TTL_SECONDS
);
return {
requiresTotp: true,
totpChallengeToken,
accessToken: '',
refreshToken: '',
expiresAt: new Date().toISOString(),
pinVerified: false,
sessionId: '',
user: await this.toPublicUser(user)
};
}
private totpLoginChallengeKey(challengeId: string) {
return `totp:login:${challengeId}`;
}
private totpLoginAttemptKey(challengeId: string) {
return `totp:login:attempts:${challengeId}`;
}
private async upsertDevice(userId: string, command: Pick<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress'>) {
return this.prisma.device.upsert({
where: { userId_fingerprint: { userId, fingerprint: command.fingerprint } },
@@ -400,6 +616,8 @@ export class AuthService {
}
});
await this.notifications.dismissOtpCodeNotifications(user.id);
return {
accessToken: await this.issueAccessToken(user, session.id, pinVerified),
refreshToken,
@@ -417,11 +635,31 @@ export class AuthService {
);
}
private normalizeOtpTarget(target: string) {
if (target.includes('@')) {
return target.trim().toLowerCase();
}
return canonicalPhoneE164(target);
}
private recipientLookupTargets(recipient: string) {
if (recipient.includes('@')) {
return [recipient.trim().toLowerCase()];
}
return phoneLookupVariants(recipient);
}
private async findUserByRecipient(recipient: string) {
if (recipient.includes('@')) {
return this.prisma.user.findFirst({
where: { email: recipient.trim().toLowerCase(), deletedAt: null, status: UserStatus.ACTIVE },
include: { pinCode: true }
});
}
const variants = phoneLookupVariants(recipient);
return this.prisma.user.findFirst({
where: recipient.includes('@')
? { email: recipient, deletedAt: null, status: UserStatus.ACTIVE }
: { phone: recipient, deletedAt: null, status: UserStatus.ACTIVE },
where: { phone: { in: variants }, deletedAt: null, status: UserStatus.ACTIVE },
include: { pinCode: true }
});
}

View File

@@ -23,6 +23,8 @@ export interface AuthTokens {
pinVerified: boolean;
sessionId: string;
user: PublicUser;
requiresTotp?: boolean;
totpChallengeToken?: string;
}
export interface PublicUser {

View File

@@ -276,112 +276,109 @@ export class FamilyService {
async sendInvite(requesterId: string, groupId: string, target: string) {
async sendInvite(requesterId: string, groupId: string, options: { target?: string; inviteeUserId?: string }) {
const group = await this.getGroupWithMembers(groupId);
this.assertMember(group, requesterId);
const trimmedTarget = target.trim();
if (!trimmedTarget) {
throw new BadRequestException('Укажите email, телефон или логин приглашаемого');
}
const invitee = await this.findUserByContact(trimmedTarget);
if (invitee?.id === requesterId) {
throw new BadRequestException('Нельзя пригласить самого себя');
}
if (invitee && group.members.some((member) => member.userId === invitee.id)) {
throw new BadRequestException('Пользователь уже состоит в семье');
}
const existingPending = invitee
? await this.prisma.familyInvite.findFirst({
where: { groupId, inviteeUserId: invitee.id, status: 'PENDING' }
const invitee = options.inviteeUserId
? await this.prisma.user.findFirst({
where: { id: options.inviteeUserId, status: 'ACTIVE', deletedAt: null }
})
: options.target
? await this.findUserByContact(options.target)
: null;
: null;
if (existingPending) {
throw new BadRequestException('Приглашение уже отправлено');
if (!invitee) {
throw new BadRequestException('Пользователь не найден. Выберите его из результатов поиска.');
}
if (invitee.id === requesterId) {
throw new BadRequestException('Нельзя пригласить самого себя');
}
if (group.members.some((member) => member.userId === invitee.id)) {
throw new BadRequestException('Пользователь уже состоит в семье');
}
const existingPending = await this.prisma.familyInvite.findFirst({
where: { groupId, inviteeUserId: invitee.id, status: 'PENDING' }
});
if (existingPending) {
throw new BadRequestException('Приглашение уже отправлено');
}
const inviter = await this.prisma.user.findUnique({ where: { id: requesterId } });
const invite = await this.prisma.familyInvite.create({
data: {
groupId,
inviterId: requesterId,
inviteeUserId: invitee?.id,
targetEmail: trimmedTarget.includes('@') ? trimmedTarget : undefined,
targetPhone: !trimmedTarget.includes('@') && /^\+?\d/.test(trimmedTarget) ? trimmedTarget : undefined,
inviteeUserId: invitee.id,
targetEmail: invitee.email ?? undefined,
targetPhone: invitee.phone ?? undefined,
expiresAt: new Date(Date.now() + INVITE_TTL_DAYS * 24 * 60 * 60 * 1000)
},
include: {
group: true,
inviter: true
}
});
if (invitee) {
await this.notifications.create(
invitee.id,
'family_invite',
'Приглашение в семью',
`${inviter?.displayName ?? 'Участник'} приглашает вас в «${group.name}»`,
{ inviteId: invite.id, groupId, groupName: group.name }
);
}
await this.notifications.create(
invitee.id,
'family_invite',
'Приглашение в семью',
`${inviter?.displayName ?? 'Участник'} приглашает вас в «${group.name}»`,
{ inviteId: invite.id, groupId, groupName: group.name }
);
return this.toInvite(invite);
}
async searchInviteUsers(requesterId: string, groupId: string, query: string) {
const group = await this.getGroupWithMembers(groupId);
this.assertMember(group, requesterId);
const trimmed = query.trim();
if (trimmed.length < 2) {
return { users: [] };
}
const excludedIds = [...group.members.map((member) => member.userId), requesterId];
const digits = trimmed.replace(/\D/g, '');
const users = await this.prisma.user.findMany({
where: {
status: 'ACTIVE',
deletedAt: null,
id: { notIn: excludedIds },
OR: [
{ displayName: { contains: trimmed, mode: 'insensitive' } },
{ username: { contains: trimmed, mode: 'insensitive' } },
...(trimmed.includes('@') ? [{ email: { contains: trimmed, mode: 'insensitive' as const } }] : []),
...(digits.length >= 4 ? [{ phone: { contains: digits } }] : [])
]
},
orderBy: { displayName: 'asc' },
take: 8,
select: {
id: true,
displayName: true,
email: true,
phone: true,
username: true,
avatarStorageKey: true
}
});
return {
users: users.map((user) => ({
id: user.id,
displayName: user.displayName,
email: user.email,
phone: user.phone,
username: user.username,
hasAvatar: Boolean(user.avatarStorageKey)
}))
};
}
@@ -610,27 +607,38 @@ export class FamilyService {
private async findUserByContact(target: string) {
if (target.includes('@')) {
return this.prisma.user.findFirst({ where: { email: target, status: 'ACTIVE' } });
const trimmed = target.trim();
if (!trimmed) {
return null;
}
const normalizedPhone = target.replace(/\D/g, '');
if (trimmed.includes('@')) {
return this.prisma.user.findFirst({
where: { email: { equals: trimmed, mode: 'insensitive' }, status: 'ACTIVE', deletedAt: null }
});
}
const digits = trimmed.replace(/\D/g, '');
if (/^\+?\d/.test(trimmed) && digits.length >= 10) {
return this.prisma.user.findFirst({
where: {
status: 'ACTIVE',
deletedAt: null,
OR: [{ phone: trimmed }, { phone: { endsWith: digits.slice(-10) } }]
}
});
}
return this.prisma.user.findFirst({
where: {
status: 'ACTIVE',
OR: [{ phone: target }, { phone: { contains: normalizedPhone.slice(-10) } }, { username: target }]
deletedAt: null,
OR: [
{ username: { equals: trimmed, mode: 'insensitive' } },
{ displayName: { equals: trimmed, mode: 'insensitive' } }
]
}
});
}

View File

@@ -16,6 +16,15 @@ interface MediaStreamPayload {
fileName?: string;
}
interface MediaUploadPayload {
purpose: 'media-upload';
storageKey: string;
contentType: string;
userId: string;
}
const UPLOAD_TTL_SECONDS = 300;
@Injectable()
export class MediaService {
constructor(
@@ -34,8 +43,7 @@ export class MediaService {
}
const extension = this.minio.extensionForContentType(contentType);
const storageKey = `avatars/${userId}/${crypto.randomUUID()}.${extension}`;
const presigned = await this.minio.createPresignedUploadUrl(storageKey, contentType);
return { ...presigned, storageKey };
return this.createUploadTarget(userId, storageKey, contentType);
}
async confirmAvatar(userId: string, storageKey: string) {
@@ -102,8 +110,7 @@ export class MediaService {
const extension = this.minio.extensionForContentType(contentType);
const storageKey = `documents/${userId}/${documentId}/${crypto.randomUUID()}.${extension}`;
const presigned = await this.minio.createPresignedUploadUrl(storageKey, contentType);
return { ...presigned, storageKey };
return this.createUploadTarget(userId, storageKey, contentType);
}
async resolveStreamToken(token: string, requesterId?: string) {
@@ -186,8 +193,7 @@ export class MediaService {
}
const extension = this.minio.extensionForContentType(contentType);
const storageKey = `families/${groupId}/${crypto.randomUUID()}.${extension}`;
const presigned = await this.minio.createPresignedUploadUrl(storageKey, contentType);
return { ...presigned, storageKey };
return this.createUploadTarget(requesterId, storageKey, contentType);
}
async confirmFamilyAvatar(requesterId: string, groupId: string, storageKey: string) {
@@ -221,8 +227,7 @@ export class MediaService {
}
const extension = this.minio.extensionForChatMedia(normalized, fileName);
const storageKey = `chat/${roomId}/${crypto.randomUUID()}.${extension}`;
const presigned = await this.minio.createPresignedUploadUrl(storageKey, normalized);
return { ...presigned, storageKey };
return this.createUploadTarget(requesterId, storageKey, normalized);
}
async getChatMediaAccessUrl(requesterId: string, roomId: string, storageKey: string, fileName?: string) {
@@ -241,6 +246,37 @@ export class MediaService {
return roomId;
}
private async createUploadTarget(userId: string, storageKey: string, contentType: string) {
const viaApi = this.config.get<string>('MINIO_UPLOAD_VIA_API', 'true') !== 'false';
const expiresAt = new Date(Date.now() + UPLOAD_TTL_SECONDS * 1000).toISOString();
if (viaApi) {
const uploadToken = await this.jwt.signAsync(
{
purpose: 'media-upload',
storageKey,
contentType,
userId
} satisfies MediaUploadPayload,
{
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
expiresIn: UPLOAD_TTL_SECONDS,
issuer: 'id.lendry.ru'
}
);
const publicApiUrl = this.config.get<string>('PUBLIC_API_URL', 'http://localhost:3001').replace(/\/$/, '');
return {
uploadUrl: `${publicApiUrl}/media/upload`,
uploadToken,
storageKey,
expiresAt
};
}
const presigned = await this.minio.createPresignedUploadUrl(storageKey, contentType, UPLOAD_TTL_SECONDS);
return { ...presigned, storageKey };
}
private async createStreamAccessUrl(
storageKey: string,
ownerId: string,

View File

@@ -121,6 +121,13 @@ export class NotificationsService {
return { count: result.count };
}
async dismissOtpCodeNotifications(userId: string) {
const result = await this.prisma.notification.deleteMany({
where: { userId, type: 'otp_code' }
});
return { count: result.count };
}
async dismissFamilyInvite(userId: string, inviteId: string) {
const items = await this.prisma.notification.findMany({
where: { userId, type: 'family_invite' }

View File

@@ -5,6 +5,7 @@ import { PrismaService } from '../infra/prisma.service';
import { SettingsService } from './settings.service';
import { MessagingService } from '../infra/messaging.service';
import { RedisService } from '../infra/redis.service';
import { NotificationsService } from './notifications.service';
@Injectable()
export class OtpService {
@@ -12,10 +13,11 @@ export class OtpService {
private readonly prisma: PrismaService,
private readonly settings: SettingsService,
private readonly messaging: MessagingService,
private readonly redis: RedisService
private readonly redis: RedisService,
private readonly notifications: NotificationsService
) {}
async send(command: { target: string; channel: string; purpose: string; userId?: string }) {
async send(command: { target: string; channel: string; purpose: string; userId?: string; ipAddress?: string }) {
const expiryMinutes = await this.settings.getNumber('OTP_EXPIRY_MINUTES', 10);
const code = String(randomInt(100000, 999999));
const expiresAt = new Date(Date.now() + expiryMinutes * 60_000);
@@ -36,6 +38,16 @@ export class OtpService {
code,
expiryMinutes
});
if (command.userId) {
const ipLabel = command.ipAddress?.trim() || 'неизвестного IP';
await this.notifications.create(
command.userId,
'otp_code',
this.purposeLabel(command.purpose),
`Попытка входа с ${ipLabel} — код для верификации: ${code}`,
{ code, ipAddress: command.ipAddress ?? null, purpose: command.purpose }
);
}
await this.redis.client.del(this.attemptKey(command.target, command.purpose));
return { sent: true, expiresAt: expiresAt.toISOString() };
}
@@ -67,10 +79,26 @@ export class OtpService {
}
await this.redis.client.del(attemptKey);
await this.prisma.authCode.update({ where: { id: authCode.id }, data: { usedAt: new Date() } });
if (authCode.userId) {
await this.notifications.dismissOtpCodeNotifications(authCode.userId);
}
return { verified: true };
}
private attemptKey(target: string, purpose: string) {
return `otp:attempts:${purpose}:${target.toLowerCase()}`;
}
private purposeLabel(purpose: string) {
switch (purpose) {
case 'register':
return 'Регистрация';
case 'login':
return 'Вход в аккаунт';
case 'password-reset':
return 'Сброс пароля';
default:
return 'Подтверждение действия';
}
}
}

View File

@@ -9,19 +9,13 @@ import { PrismaService } from '../infra/prisma.service';
interface UpdateProfileCommand {
userId: string;
firstName?: string;
lastName?: string;
bio?: string;
age?: number;
gender?: string;
birthDate?: string;
}
@@ -151,63 +145,36 @@ export class ProfileService {
async updateProfile(command: UpdateProfileCommand) {
const displayName = [command.firstName, command.lastName].filter(Boolean).join(' ').trim();
const birthDate = command.birthDate ? this.parseBirthDate(command.birthDate) : undefined;
const user = await this.prisma.user.update({
where: { id: command.userId },
data: {
displayName: displayName || undefined,
gender: command.gender,
birthDate,
profile: {
upsert: {
create: {
firstName: command.firstName,
lastName: command.lastName,
bio: command.bio,
age: command.age,
gender: command.gender
},
update: {
firstName: command.firstName,
lastName: command.lastName,
bio: command.bio,
age: command.age,
gender: command.gender
}
}
}
},
include: { profile: true }
});
return this.toResponse(user);
}
@@ -430,12 +397,30 @@ export class ProfileService {
backupEmail: user.backupEmail,
backupPhone: user.backupPhone
backupPhone: user.backupPhone,
birthDate: user.birthDate?.toISOString().slice(0, 10) ?? null
};
}
private parseBirthDate(value: string): Date {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new BadRequestException('Укажите корректную дату рождения');
}
const parsed = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException('Укажите корректную дату рождения');
}
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
if (parsed.getTime() > today.getTime()) {
throw new BadRequestException('Дата рождения не может быть в будущем');
}
return parsed;
}
}

View File

@@ -6,9 +6,21 @@ import { PrismaService } from '../infra/prisma.service';
export class SecurityService {
constructor(private readonly prisma: PrismaService) {}
async listActiveDevices(userId: string) {
return this.prisma.device.findMany({
where: { userId },
async listActiveDevices(userId: string, exceptSessionId?: string) {
let exceptDeviceId: string | undefined;
if (exceptSessionId) {
const currentSession = await this.prisma.session.findFirst({
where: { id: exceptSessionId, userId },
select: { deviceId: true }
});
exceptDeviceId = currentSession?.deviceId ?? undefined;
}
const devices = await this.prisma.device.findMany({
where: {
userId,
...(exceptDeviceId ? { id: { not: exceptDeviceId } } : {})
},
orderBy: { lastSeenAt: 'desc' },
include: {
sessions: {
@@ -17,6 +29,8 @@ export class SecurityService {
}
}
});
return devices.filter((device) => device.sessions.length > 0);
}
async listActiveSessions(userId: string) {
@@ -57,9 +71,13 @@ export class SecurityService {
});
}
async revokeAllSessions(userId: string) {
async revokeAllSessions(userId: string, exceptSessionId?: string) {
return this.prisma.session.updateMany({
where: { userId, status: { in: [SessionStatus.ACTIVE, SessionStatus.LOCKED] } },
where: {
userId,
status: { in: [SessionStatus.ACTIVE, SessionStatus.LOCKED] },
...(exceptSessionId ? { id: { not: exceptSessionId } } : {})
},
data: { status: SessionStatus.REVOKED }
});
}

View File

@@ -0,0 +1,163 @@
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { authenticator } from 'otplib';
import { EncryptionService } from '../infra/encryption.service';
import { PrismaService } from '../infra/prisma.service';
import { SettingsService } from './settings.service';
@Injectable()
export class TotpService {
private readonly totp = authenticator.clone({ window: 2 });
constructor(
private readonly prisma: PrismaService,
private readonly encryption: EncryptionService,
private readonly settings: SettingsService
) {}
async getStatus(userId: string) {
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
return { isEnabled: Boolean(record?.isEnabled) };
}
async isEnabledForUser(userId: string) {
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
return Boolean(record?.isEnabled);
}
async setup(userId: string) {
await this.ensureUserExists(userId);
const issuer = await this.resolveIssuer();
const user = await this.prisma.user.findUnique({ where: { id: userId } });
const accountLabel = this.buildAccountLabel(user, userId);
const existing = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
if (existing && !existing.isEnabled) {
const secret = this.tryDecryptSecret(existing.secretEnc);
if (secret) {
return {
secret,
otpauthUrl: this.totp.keyuri(accountLabel, issuer, secret)
};
}
}
const secret = this.totp.generateSecret();
const secretEnc = this.encryption.encrypt(secret);
this.assertSecretRoundTrip(secret, secretEnc);
const otpauthUrl = this.totp.keyuri(accountLabel, issuer, secret);
await this.prisma.userTotpSecret.upsert({
where: { userId },
create: {
userId,
secretEnc,
isEnabled: false
},
update: {
secretEnc,
isEnabled: false
}
});
return { secret, otpauthUrl };
}
async enable(userId: string, code: string) {
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
if (!record) {
throw new BadRequestException('Сначала начните настройку приложения-аутентификатора');
}
if (record.isEnabled) {
return { isEnabled: true };
}
this.assertValidCode(record.secretEnc, code);
await this.prisma.userTotpSecret.update({
where: { userId },
data: { isEnabled: true }
});
return { isEnabled: true };
}
async disable(userId: string, code: string) {
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
if (!record?.isEnabled) {
throw new BadRequestException('Двухфакторная аутентификация не включена');
}
this.assertValidCode(record.secretEnc, code);
await this.prisma.userTotpSecret.delete({ where: { userId } });
return { isEnabled: false };
}
async verifyEnabledCode(userId: string, code: string) {
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
if (!record?.isEnabled) {
return false;
}
return this.isValidCode(record.secretEnc, code);
}
private async resolveIssuer() {
const projectName = (await this.settings.getValue('PROJECT_NAME', 'MVK ID')).trim();
return projectName || 'MVK ID';
}
private buildAccountLabel(
user: { email: string | null; phone: string | null; username: string | null } | null,
userId: string
) {
return user?.email ?? user?.phone ?? user?.username ?? userId;
}
private tryDecryptSecret(secretEnc: string) {
try {
const secret = this.encryption.decrypt(secretEnc).trim();
return secret.length > 0 ? secret : null;
} catch {
return null;
}
}
private assertSecretRoundTrip(secret: string, secretEnc: string) {
const decrypted = this.encryption.decrypt(secretEnc);
if (decrypted !== secret) {
throw new InternalServerErrorException('Не удалось сохранить секрет аутентификатора');
}
const probeToken = this.totp.generate(secret);
if (!this.totp.verify({ token: probeToken, secret: decrypted })) {
throw new InternalServerErrorException('Не удалось проверить секрет аутентификатора');
}
}
private assertValidCode(secretEnc: string, code: string) {
if (!this.isValidCode(secretEnc, code)) {
throw new BadRequestException(
'Неверный код аутентификатора. Убедитесь, что отсканирован последний QR-код, и повторите настройку при необходимости.'
);
}
}
private isValidCode(secretEnc: string, code: string) {
const normalized = code.replace(/\s/g, '');
if (!/^\d{6}$/.test(normalized)) {
return false;
}
const secret = this.tryDecryptSecret(secretEnc);
if (!secret) {
return false;
}
return this.totp.verify({ token: normalized, secret });
}
private async ensureUserExists(userId: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user || user.deletedAt) {
throw new NotFoundException('Пользователь не найден');
}
}
}

View File

@@ -0,0 +1,39 @@
export function phoneLookupVariants(value: string): string[] {
const trimmed = value.trim();
const digits = trimmed.replace(/\D/g, '');
const variants = new Set<string>([trimmed, digits]);
if (digits.length === 11 && digits.startsWith('8')) {
const normalized = `7${digits.slice(1)}`;
variants.add(normalized);
variants.add(`+${normalized}`);
} else if (digits.length === 11 && digits.startsWith('7')) {
variants.add(`+${digits}`);
variants.add(`8${digits.slice(1)}`);
} else if (digits.length === 10) {
variants.add(`7${digits}`);
variants.add(`+7${digits}`);
}
if (trimmed.startsWith('+') && digits) {
variants.add(`+${digits}`);
}
return [...variants];
}
export function canonicalPhoneE164(value: string): string {
const digits = value.replace(/\D/g, '');
if (!digits) {
return value.trim();
}
let normalized = digits;
if (normalized.length === 11 && normalized.startsWith('8')) {
normalized = `7${normalized.slice(1)}`;
} else if (normalized.length === 10) {
normalized = `7${normalized}`;
}
return `+${normalized}`;
}