import { ForbiddenException, Injectable, Logger, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; import { createHash, randomBytes } from 'crypto'; import { IsNull, Repository } from 'typeorm'; import { config } from '../../config'; import { Organization, User, UserSession } from '../../entities'; import { accessProblem, type AccessTokenPayload, type AuthUser } from '../../common/auth'; import { AspansIdClient } from '../passport/aspans-id.client'; import { PassportClient, PassportPerson } from '../passport/passport.client'; import { composeFullName } from '../users-sync/profile'; function sha256(v: string): string { return createHash('sha256').update(v).digest('hex'); } /** * Вход только через паспорт: по логину и паролю (паспорт проверяет пароль и привязку к patrol) * или через Aspans ID (SSO). Здесь — проверка, что человек заведён в patrol, и свои токены сессии. */ @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); constructor( private readonly jwt: JwtService, private readonly passport: PassportClient, private readonly aspansId: AspansIdClient, @InjectRepository(User) private readonly users: Repository, @InjectRepository(UserSession) private readonly sessions: Repository, @InjectRepository(Organization) private readonly orgs: Repository, ) {} publicConfig() { return { passport: { enabled: this.passport.enabled }, aspansId: { enabled: this.aspansId.enabled, baseUrl: this.aspansId.baseUrl || null }, }; } async login(login: string, password: string, userAgent?: string) { const person = await this.passport.login(login.trim(), password); const user = await this.resolve(person.uuid, person.username, person); return this.issue(user.id, userAgent); } ssoStart(origin: string | undefined, prompt?: string) { return this.aspansId.start(origin, prompt); } async ssoCallback(code: string, codeVerifier: string, redirectUri: string, userAgent?: string) { const info = await this.aspansId.exchange(code, codeVerifier, redirectUri); const user = await this.resolve(info.uuid, info.username, null); return this.issue(user.id, userAgent); } /** * Человек паспорта → пользователь patrol. Не заведён здесь — не пускаем (кроме владельцев * платформы из PLATFORM_ADMIN_USERNAMES: их запись создаётся при первом входе). */ private async resolve(uuid: string, username: string | null, person: PassportPerson | null) { const isOwner = !!username && config.platformAdminUsernames.includes(username.toLowerCase()); let user = await this.users.findOneBy({ passportUuid: uuid }); if (!user && isOwner) { const p = person ?? (await this.passport.getUser(uuid).catch(() => null)); user = await this.users.save({ passportUuid: uuid, login: username, firstName: p?.firstName ?? null, lastName: p?.lastName ?? null, middleName: p?.middleName ?? null, email: p?.email ?? null, phone: p?.phone ?? null, fullName: composeFullName(p ?? {}, 'Владелец платформы'), isPlatformAdmin: true, organizationId: null, }); this.logger.log(`Владелец платформы ${username} вошёл впервые — создана запись`); } if (!user) { throw new ForbiddenException('Вы не подключены к «Обходам». Обратитесь к администратору своей организации'); } const patch: Partial = { lastLoginAt: new Date() }; if (isOwner && !user.isPlatformAdmin) patch.isPlatformAdmin = true; // Паспорт только что отдал свежий профиль — подтягиваем, даже если шина выключена. if (person) { Object.assign(patch, { login: person.username, firstName: person.firstName, lastName: person.lastName, middleName: person.middleName, email: person.email, phone: person.phone, passportEnabled: person.isEnabled, }); patch.fullName = composeFullName({ ...user, ...patch }, user.fullName); } const merged = { ...user, ...patch } as User; const org = merged.organizationId ? await this.orgs.findOneBy({ id: merged.organizationId }) : null; const problem = accessProblem(merged, org); if (problem) throw new ForbiddenException(problem); await this.users.update({ id: user.id }, patch); return merged; } async issue(userId: string, userAgent?: string) { const refresh = randomBytes(32).toString('base64url'); await this.sessions.save({ userId, refreshHash: sha256(refresh), userAgent: userAgent?.slice(0, 300) ?? null, expiresAt: new Date(Date.now() + config.refreshTtlDays * 864e5), }); return { accessToken: await this.accessToken(userId), refreshToken: refresh }; } accessToken(userId: string) { const payload: AccessTokenPayload = { sub: userId, typ: 'access' }; return this.jwt.signAsync(payload, { expiresIn: config.accessTtlSeconds }); } // Ротация: старый refresh гасим, выдаём новый. async refresh(refresh: string | undefined, userAgent?: string) { if (!refresh) throw new UnauthorizedException('Нужно войти'); const session = await this.sessions.findOneBy({ refreshHash: sha256(refresh), revokedAt: IsNull() }); if (!session || session.expiresAt < new Date()) throw new UnauthorizedException('Сессия закончилась'); const user = await this.users.findOneBy({ id: session.userId }); if (!user) throw new UnauthorizedException('Пользователь не найден'); const org = user.organizationId ? await this.orgs.findOneBy({ id: user.organizationId }) : null; const problem = accessProblem(user, org); if (problem) throw new UnauthorizedException(problem); await this.sessions.update({ id: session.id }, { revokedAt: new Date() }); return this.issue(user.id, userAgent); } async logout(refresh: string | undefined) { if (refresh) await this.sessions.update({ refreshHash: sha256(refresh) }, { revokedAt: new Date() }); } async me(user: AuthUser) { const org = user.organizationId ? await this.orgs.findOneBy({ id: user.organizationId }) : null; const full = await this.users.findOneByOrFail({ id: user.id }); return { id: user.id, passportUuid: full.passportUuid, login: user.login, fullName: user.fullName, email: full.email, phone: full.phone, isPlatformAdmin: user.isPlatformAdmin, organization: org && { id: org.id, name: org.name, bin: org.bin }, role: user.roleId ? { id: user.roleId, name: user.roleName } : null, allSites: user.allSites, permissions: [...user.permissions], }; } }