aspans_patrol/apps/api/src/modules/auth/auth.service.ts
2026-10-22 10:53:02 +05:00

160 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<User>,
@InjectRepository(UserSession) private readonly sessions: Repository<UserSession>,
@InjectRepository(Organization) private readonly orgs: Repository<Organization>,
) {}
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<User> = { 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],
};
}
}