more fix and update
This commit is contained in:
@@ -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 }
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user