160 lines
7.1 KiB
TypeScript
160 lines
7.1 KiB
TypeScript
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],
|
||
};
|
||
}
|
||
}
|