more fix and update
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface AuthTokens {
|
||||
pinVerified: boolean;
|
||||
sessionId: string;
|
||||
user: PublicUser;
|
||||
requiresTotp?: boolean;
|
||||
totpChallengeToken?: string;
|
||||
}
|
||||
|
||||
export interface PublicUser {
|
||||
|
||||
@@ -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' } }
|
||||
]
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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 'Подтверждение действия';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
|
||||
163
apps/sso-core/src/domain/totp.service.ts
Normal file
163
apps/sso-core/src/domain/totp.service.ts
Normal 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('Пользователь не найден');
|
||||
}
|
||||
}
|
||||
}
|
||||
39
apps/sso-core/src/infra/phone.util.ts
Normal file
39
apps/sso-core/src/infra/phone.util.ts
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user