174 lines
5.0 KiB
TypeScript
174 lines
5.0 KiB
TypeScript
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
import * as bcrypt from 'bcryptjs';
|
|
import { SessionStatus } from '../generated/prisma/client';
|
|
import { PrismaService } from '../infra/prisma.service';
|
|
import { AuthService } from './auth.service';
|
|
import { SettingsService } from './settings.service';
|
|
import { isProtectedSystemAccount } from './system-account.util';
|
|
|
|
export interface SessionState {
|
|
id: string;
|
|
userId: string;
|
|
pinVerified: boolean;
|
|
status: SessionStatus;
|
|
requiresPin: boolean;
|
|
}
|
|
|
|
@Injectable()
|
|
export class SessionService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly settings: SettingsService,
|
|
private readonly auth: AuthService
|
|
) {}
|
|
|
|
async resolveSessionState(sessionId: string): Promise<SessionState | null> {
|
|
const session = await this.prisma.session.findUnique({
|
|
where: { id: sessionId },
|
|
include: { user: { include: { pinCode: true } } }
|
|
});
|
|
|
|
if (!session || session.status === SessionStatus.REVOKED || session.expiresAt < new Date()) {
|
|
return null;
|
|
}
|
|
|
|
if (isProtectedSystemAccount(session.user)) {
|
|
await this.prisma.session.update({
|
|
where: { id: sessionId },
|
|
data: { status: SessionStatus.REVOKED }
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const pinEnabled = Boolean(session.user.pinCode?.isEnabled);
|
|
let pinVerified = session.pinVerified;
|
|
let status = session.status;
|
|
|
|
if (pinEnabled && pinVerified && status === SessionStatus.ACTIVE) {
|
|
const timeoutMinutes = await this.settings.getNumber('PIN_LOCK_TIMEOUT_MINUTES', 15);
|
|
const threshold = new Date(Date.now() - timeoutMinutes * 60_000);
|
|
if (session.updatedAt < threshold) {
|
|
await this.prisma.session.update({
|
|
where: { id: sessionId },
|
|
data: { pinVerified: false, status: SessionStatus.LOCKED }
|
|
});
|
|
pinVerified = false;
|
|
status = SessionStatus.LOCKED;
|
|
}
|
|
}
|
|
|
|
const requiresPin = pinEnabled && (!pinVerified || status === SessionStatus.LOCKED);
|
|
|
|
return {
|
|
id: session.id,
|
|
userId: session.userId,
|
|
pinVerified,
|
|
status,
|
|
requiresPin
|
|
};
|
|
}
|
|
|
|
async touchSessionActivity(sessionId: string) {
|
|
const state = await this.resolveSessionState(sessionId);
|
|
if (!state || state.requiresPin) {
|
|
return state;
|
|
}
|
|
|
|
await this.prisma.session.update({
|
|
where: { id: sessionId },
|
|
data: { updatedAt: new Date() }
|
|
});
|
|
|
|
return state;
|
|
}
|
|
|
|
async validateSession(userId: string, sessionId: string, touchActivity: boolean) {
|
|
const state = touchActivity
|
|
? await this.touchSessionActivity(sessionId)
|
|
: await this.resolveSessionState(sessionId);
|
|
|
|
if (!state || state.userId !== userId) {
|
|
throw new UnauthorizedException('Сессия недействительна');
|
|
}
|
|
|
|
return {
|
|
requiresPin: state.requiresPin,
|
|
sessionId: state.id,
|
|
pinVerified: state.pinVerified
|
|
};
|
|
}
|
|
|
|
async refreshSession(refreshToken: string, sessionId?: string) {
|
|
const sessions = await this.prisma.session.findMany({
|
|
where: {
|
|
...(sessionId ? { id: sessionId } : {}),
|
|
status: { not: SessionStatus.REVOKED },
|
|
expiresAt: { gt: new Date() }
|
|
},
|
|
include: { user: { include: { pinCode: true } } },
|
|
orderBy: { updatedAt: 'desc' },
|
|
take: sessionId ? 1 : 50
|
|
});
|
|
|
|
let matchedSession: (typeof sessions)[number] | null = null;
|
|
for (const session of sessions) {
|
|
const matches = await bcrypt.compare(refreshToken, session.refreshHash);
|
|
if (matches) {
|
|
matchedSession = session;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!matchedSession) {
|
|
throw new UnauthorizedException('Refresh token недействителен');
|
|
}
|
|
|
|
const state = await this.resolveSessionState(matchedSession.id);
|
|
if (!state) {
|
|
throw new UnauthorizedException('Сессия недействительна');
|
|
}
|
|
|
|
if (state.requiresPin) {
|
|
const user = await this.auth.getMe(matchedSession.userId);
|
|
return {
|
|
requiresPin: true,
|
|
sessionId: state.id,
|
|
pinVerified: false,
|
|
user
|
|
};
|
|
}
|
|
|
|
await this.prisma.session.update({
|
|
where: { id: matchedSession.id },
|
|
data: { updatedAt: new Date() }
|
|
});
|
|
|
|
const user = await this.auth.getMe(matchedSession.userId);
|
|
|
|
return {
|
|
requiresPin: false,
|
|
accessToken: await this.auth.issueAccessToken(matchedSession.user, matchedSession.id, true),
|
|
sessionId: matchedSession.id,
|
|
pinVerified: true,
|
|
user
|
|
};
|
|
}
|
|
|
|
async lockExpiredPinSessions() {
|
|
const timeoutMinutes = await this.settings.getNumber('PIN_LOCK_TIMEOUT_MINUTES', 15);
|
|
const threshold = new Date(Date.now() - timeoutMinutes * 60_000);
|
|
return this.prisma.session.updateMany({
|
|
where: {
|
|
pinVerified: true,
|
|
updatedAt: { lt: threshold },
|
|
status: SessionStatus.ACTIVE,
|
|
user: { pinCode: { isEnabled: true } }
|
|
},
|
|
data: {
|
|
pinVerified: false,
|
|
status: SessionStatus.LOCKED
|
|
}
|
|
});
|
|
}
|
|
}
|