fix sso core folder

This commit is contained in:
lendry
2026-06-24 14:45:45 +03:00
parent 995adeedd4
commit d2bbf35d40
45 changed files with 6592 additions and 1 deletions

View File

@@ -0,0 +1,519 @@
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { randomBytes, randomInt } from 'node:crypto';
import { DeviceType, Prisma, SessionStatus, User, UserStatus } from '../generated/prisma/client';
import { PrismaService } from '../infra/prisma.service';
import { RedisService } from '../infra/redis.service';
import { LdapClientService, LdapConnectionConfig } from '../infra/ldap-client.service';
import { normalizeLdapUserFilter, resolveLdapBindIdentity, resolveLdapPort, validateLdapConfig } from '../infra/ldap-config.util';
import { AuthTokens, LoginCommand, PublicUser, RegisterCommand } from './dto';
import { AccessService } from './access.service';
import { SettingsService } from './settings.service';
const FIRST_ADMIN_LOCK = 'locks:first-super-admin';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly access: AccessService,
private readonly settings: SettingsService,
private readonly ldapClient: LdapClientService
) {}
async register(command: RegisterCommand): Promise<PublicUser> {
if (!command.email && !command.phone) {
throw new BadRequestException('Укажите почту или телефон');
}
const passwordHash = command.password ? await bcrypt.hash(command.password, 12) : null;
const lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000);
if (!lockAcquired) {
throw new BadRequestException('Регистрация временно недоступна, повторите попытку');
}
try {
const user = await this.prisma.$transaction(
async (tx) => {
const usersCount = await tx.user.count({ where: { deletedAt: null } });
return tx.user.create({
data: {
email: command.email,
phone: command.phone,
passwordHash,
displayName: command.displayName,
username: command.username,
isSuperAdmin: usersCount === 0
}
});
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable }
);
return await this.toPublicUser(user);
} catch (error) {
if (this.isUniqueError(error)) {
throw new BadRequestException('Пользователь с такими данными уже существует');
}
throw error;
} finally {
await this.redis.releaseLock(FIRST_ADMIN_LOCK);
}
}
async login(command: LoginCommand): Promise<AuthTokens> {
const user = await this.prisma.user.findFirst({
where: {
OR: [{ email: command.login }, { phone: command.login }, { username: command.login }],
deletedAt: null,
status: UserStatus.ACTIVE
},
include: { pinCode: true }
});
if (!user || user.status !== UserStatus.ACTIVE) {
throw new UnauthorizedException('Неверный логин или пароль');
}
if (!user.passwordHash) {
throw new UnauthorizedException('Для этого аккаунта используйте вход по коду');
}
const passwordMatches = await bcrypt.compare(command.password, user.passwordHash);
if (!passwordMatches) {
await this.writeSignInEvent(user.id, false, command, undefined, 'Неверный пароль');
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;
}
async identify(login: string) {
const user = await this.findUserByRecipient(login);
const methods: Array<{ kind: string; channel: string; masked: string }> = [];
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 },
{ channel: 'backupEmail', value: user.backupEmail },
{ channel: 'backupPhone', value: user.backupPhone }
];
for (const item of channels) {
if (item.value && item.value !== login) {
methods.push({ kind: 'otp', channel: item.channel, masked: this.maskTarget(item.value) });
}
}
}
return {
exists: Boolean(user),
hasPassword: Boolean(user?.passwordHash),
isPinEnabled: Boolean(user?.pinCode?.isEnabled),
methods
};
}
async sendOtp(recipient: string, channel?: string) {
const existingUser = await this.findUserByRecipient(recipient);
const target = this.resolveOtpTarget(recipient, channel, existingUser);
const code = String(randomInt(100000, 999999));
const expiresAt = new Date(Date.now() + 5 * 60_000);
await this.prisma.authCode.create({
data: {
userId: existingUser?.id,
target,
channel: target.includes('@') ? 'email' : 'sms',
purpose: 'passwordless-login',
codeHash: await bcrypt.hash(code, 10),
expiresAt
}
});
console.log('[OTP MOCK] Code for', target, 'is:', code);
return { sent: true, expiresAt: expiresAt.toISOString(), maskedTarget: this.maskTarget(target) };
}
async verifyOtp(command: LoginCommand & { recipient: string; code: string }) {
const existingUser = await this.findUserByRecipient(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 }] : [])]
},
orderBy: { createdAt: 'desc' }
});
if (!authCode) throw new UnauthorizedException('Код входа не найден или истек');
const matches = await bcrypt.compare(command.code, authCode.codeHash);
if (!matches) throw new UnauthorizedException('Неверный код входа');
await this.prisma.authCode.update({ where: { id: authCode.id }, data: { usedAt: new Date() } });
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 };
}
private resolveOtpTarget(recipient: string, channel: string | undefined, user: { email: string | null; phone: string | null; backupEmail: string | null; backupPhone: string | null } | null) {
if (!channel || channel === 'primary' || !user) return recipient;
const map: Record<string, string | null> = {
email: user.email,
phone: user.phone,
backupEmail: user.backupEmail,
backupPhone: user.backupPhone
};
return map[channel] ?? recipient;
}
private maskTarget(value: string) {
if (value.includes('@')) {
const [name, domain] = value.split('@');
const visible = name.slice(0, 1);
return `${visible}${'*'.repeat(Math.max(name.length - 1, 1))}@${domain}`;
}
const tail = value.slice(-2);
const head = value.slice(0, value.startsWith('+') ? 2 : 1);
return `${head}${'*'.repeat(Math.max(value.length - head.length - 2, 2))}${tail}`;
}
async loginWithPassword(command: LoginCommand & { tempAuthToken?: string }) {
const user = command.tempAuthToken
? await this.findUserByTempPasswordToken(command.tempAuthToken)
: await this.findUserByRecipient(command.login);
if (!user?.passwordHash || user.status !== UserStatus.ACTIVE) throw new UnauthorizedException('Пользователь не найден или заблокирован');
const matches = await bcrypt.compare(command.password, user.passwordHash);
if (!matches) {
await this.writeSignInEvent(user.id, false, command, undefined, 'Неверный пароль');
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;
}
async loginWithLdap(command: {
username: string;
password: string;
fingerprint: string;
deviceName?: string;
deviceType?: string;
ipAddress?: string;
userAgent?: string;
}): Promise<AuthTokens> {
const enabled = await this.settings.getBoolean('LDAP_ENABLED', false);
if (!enabled) {
throw new UnauthorizedException('LDAP-аутентификация отключена');
}
const ldapConfig = await this.getLdapConfig();
validateLdapConfig(ldapConfig);
let ldapResult;
try {
ldapResult = await this.ldapClient.authenticate(ldapConfig, command.username, command.password);
} catch (error) {
throw new UnauthorizedException(error instanceof Error ? error.message : 'Неверный логин или пароль LDAP');
}
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 auth;
}
private async getLdapConfig(): Promise<LdapConnectionConfig> {
const useLdaps = await this.settings.getBoolean('LDAP_USE_LDAPS', false);
const rawPort = await this.settings.getNumber('LDAP_PORT', useLdaps ? 636 : 389);
const domain = await this.settings.getValue('LDAP_DOMAIN', '');
const bindUsername = await this.settings.getValue('LDAP_BIND_USERNAME', '');
const bindDnRaw = await this.settings.getValue('LDAP_BIND_DN', '');
const bindDn = resolveLdapBindIdentity(bindUsername, bindDnRaw, domain);
return {
host: await this.settings.getValue('LDAP_HOST', ''),
port: resolveLdapPort(rawPort, useLdaps),
useLdaps,
skipTlsVerify: await this.settings.getBoolean('LDAP_SKIP_TLS_VERIFY', false),
domain,
baseDn: await this.settings.getValue('LDAP_BASE_DN', ''),
bindDn,
bindPassword: await this.settings.getValue('LDAP_BIND_PASSWORD', ''),
userFilter: normalizeLdapUserFilter(
await this.settings.getValue(
'LDAP_USER_FILTER',
'(|(sAMAccountName={username})(userPrincipalName={username})(mail={username})(uid={username}))'
)
),
usernameAttr: await this.settings.getValue('LDAP_USERNAME_ATTR', 'sAMAccountName'),
emailAttr: await this.settings.getValue('LDAP_EMAIL_ATTR', 'mail'),
displayNameAttr: await this.settings.getValue('LDAP_DISPLAY_NAME_ATTR', 'displayName')
};
}
private async findOrCreateLdapUser(ldapResult: { dn: string; username: string; email?: string; displayName: string }) {
const linked = await this.prisma.linkedAccount.findUnique({
where: { providerName_providerId: { providerName: 'ldap', providerId: ldapResult.dn } },
include: { user: { include: { pinCode: true } } }
});
if (linked?.user && !linked.user.deletedAt && linked.user.status === UserStatus.ACTIVE) {
return linked.user;
}
if (ldapResult.email) {
const byEmail = await this.prisma.user.findFirst({
where: { email: ldapResult.email, deletedAt: null, status: UserStatus.ACTIVE },
include: { pinCode: true }
});
if (byEmail) {
await this.prisma.linkedAccount.upsert({
where: { providerName_providerId: { providerName: 'ldap', providerId: ldapResult.dn } },
create: {
userId: byEmail.id,
providerName: 'ldap',
providerId: ldapResult.dn,
email: ldapResult.email,
displayName: ldapResult.displayName
},
update: { email: ldapResult.email, displayName: ldapResult.displayName }
});
return byEmail;
}
}
const lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000);
if (!lockAcquired) {
throw new BadRequestException('Регистрация временно недоступна, повторите попытку');
}
try {
return await this.prisma.$transaction(
async (tx) => {
const usersCount = await tx.user.count({ where: { deletedAt: null } });
return tx.user.create({
data: {
email: ldapResult.email ?? undefined,
username: ldapResult.username,
displayName: ldapResult.displayName,
passwordHash: null,
isSuperAdmin: usersCount === 0,
linkedAccounts: {
create: {
providerName: 'ldap',
providerId: ldapResult.dn,
email: ldapResult.email,
displayName: ldapResult.displayName
}
}
},
include: { pinCode: true }
});
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable }
);
} finally {
await this.redis.releaseLock(FIRST_ADMIN_LOCK);
}
}
private async upsertDevice(userId: string, command: Pick<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress'>) {
return this.prisma.device.upsert({
where: { userId_fingerprint: { userId, fingerprint: command.fingerprint } },
create: {
userId,
fingerprint: command.fingerprint,
name: command.deviceName ?? 'Новое устройство',
type: this.toDeviceType(command.deviceType),
lastIp: command.ipAddress
},
update: {
lastSeenAt: new Date(),
lastIp: command.ipAddress,
name: command.deviceName ?? undefined,
type: this.toDeviceType(command.deviceType)
}
});
}
private async createAuthTokens(user: User & { pinCode?: { isEnabled: boolean } | null }, deviceId: string, command: Pick<LoginCommand, 'ipAddress' | 'userAgent'>): Promise<AuthTokens> {
const pinVerified = !user.pinCode?.isEnabled;
const refreshToken = randomBytes(48).toString('base64url');
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
const session = await this.prisma.session.create({
data: {
userId: user.id,
deviceId,
refreshHash: await bcrypt.hash(refreshToken, 12),
pinVerified,
status: pinVerified ? SessionStatus.ACTIVE : SessionStatus.LOCKED,
ipAddress: command.ipAddress,
userAgent: command.userAgent,
expiresAt
}
});
return {
accessToken: await this.issueAccessToken(user, session.id, pinVerified),
refreshToken,
expiresAt: expiresAt.toISOString(),
pinVerified,
user: await this.toPublicUser(user),
sessionId: session.id
};
}
private async issueTempPasswordToken(userId: string) {
return this.jwt.signAsync(
{ sub: userId, typ: 'password_challenge' },
{ secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'), expiresIn: '5m', issuer: 'id.lendry.ru' }
);
}
private async findUserByRecipient(recipient: string) {
return this.prisma.user.findFirst({
where: recipient.includes('@')
? { email: recipient, deletedAt: null, status: UserStatus.ACTIVE }
: { phone: recipient, deletedAt: null, status: UserStatus.ACTIVE },
include: { pinCode: true }
});
}
private async findUserByTempPasswordToken(tempAuthToken: string) {
const payload = await this.jwt.verifyAsync<{ sub: string; typ: string }>(tempAuthToken, {
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
issuer: 'id.lendry.ru'
});
if (payload.typ !== 'password_challenge') throw new UnauthorizedException('Временный токен недействителен');
const user = await this.prisma.user.findUnique({ where: { id: payload.sub }, include: { pinCode: true } });
if (!user || user.deletedAt || user.status !== UserStatus.ACTIVE) {
throw new UnauthorizedException('Пользователь не найден или заблокирован');
}
return user;
}
private async findOrCreatePasswordlessUser(recipient: string) {
const existingUser = await this.findUserByRecipient(recipient);
if (existingUser) return existingUser;
const lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000);
if (!lockAcquired) throw new BadRequestException('Регистрация временно недоступна, повторите попытку');
try {
return this.prisma.$transaction(
async (tx) => {
const usersCount = await tx.user.count({ where: { deletedAt: null } });
return tx.user.create({
data: {
email: recipient.includes('@') ? recipient : undefined,
phone: recipient.includes('@') ? undefined : recipient,
passwordHash: null,
displayName: recipient,
isSuperAdmin: usersCount === 0
}
});
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable }
);
} finally {
await this.redis.releaseLock(FIRST_ADMIN_LOCK);
}
}
async getMe(userId: string): Promise<PublicUser> {
const user = await this.prisma.user.findUnique({
where: { id: userId }
});
if (!user || user.deletedAt || user.status !== UserStatus.ACTIVE) {
throw new UnauthorizedException('Пользователь не найден или заблокирован');
}
return this.toPublicUser(user);
}
async toPublicUser(user: Pick<User, 'id' | 'email' | 'phone' | 'backupEmail' | 'backupPhone' | 'displayName' | 'username' | 'avatarUrl' | 'avatarStorageKey' | 'isSuperAdmin' | 'status'>): Promise<PublicUser> {
const access = await this.access.getUserAccess(user.id, user.isSuperAdmin);
return {
id: user.id,
email: user.email,
phone: user.phone,
backupEmail: user.backupEmail,
backupPhone: user.backupPhone,
displayName: user.displayName,
username: user.username,
avatarUrl: user.avatarUrl,
hasAvatar: Boolean(user.avatarStorageKey || user.avatarUrl),
isSuperAdmin: user.isSuperAdmin,
status: user.status,
roles: access.roles,
permissions: access.permissions,
canAccessAdmin: access.canAccessAdmin,
canManageRoles: access.canManageRoles,
canManageOAuth: access.canManageOAuth,
canManageUsers: access.canManageUsers,
canManageSettings: access.canManageSettings,
canViewUsers: access.canViewUsers,
canViewUserDocuments: access.canViewUserDocuments
};
}
async issueAccessToken(user: Pick<User, 'id' | 'isSuperAdmin'>, sessionId: string, pinVerified: boolean) {
return this.jwt.signAsync(
{
sub: user.id,
sessionId,
pinVerified,
isSuperAdmin: user.isSuperAdmin
},
{
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
expiresIn: '15m',
issuer: 'id.lendry.ru'
}
);
}
private async writeSignInEvent(userId: string, success: boolean, command: LoginCommand, deviceId?: string, reason?: string) {
await this.prisma.signInEvent.create({
data: {
userId,
deviceId,
success,
reason,
ipAddress: command.ipAddress,
userAgent: command.userAgent
}
});
}
private toDeviceType(value?: string): DeviceType {
if (!value) return DeviceType.WEB;
const normalized = value.toUpperCase();
return Object.values(DeviceType).includes(normalized as DeviceType) ? (normalized as DeviceType) : DeviceType.OTHER;
}
private isUniqueError(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002';
}
}