feat(auth): шаг 0.6 — TTL access 15 минут + хэширование refresh/reset-токенов в БД
Some checks are pending
Branch Check / Type Check & Build (push) Waiting to run

- server/utils/jwt.ts: дефолт JWT_ACCESS_EXPIRES 30d → 15m (env сохранено), hashToken() (sha256 hex)
- migrations/0082_token_hashes.sql: *_hash колонки в user_sessions/users, DELETE FROM user_sessions (глобальный разлогин), legacy plain-колонки сохранены, но не пишутся
- storage: lookup/отзыв сессий и reset-токенов по хэшу; markSessionReplaced по id сессии
- auth.service: ротация в rotateFamilySession(), grace-period ротирует активную сессию вместо возврата plain-токена
- auth.core.routes: forgot/reset-password пишут/ищут sha256-хэш, plain только в письме
- tests/token-security.test.ts: 12 тестов (выпуск по хэшу, ротация, reuse detection, grace, reset)
- .env.example, swagger, IMPLEMENTATION_LOG.md обновлены

Проверки: npm run check чисто, vitest 118/118, lint 0 errors, build собирается
This commit is contained in:
2026-09-08 11:35:32 +03:00
parent 639036c87e
commit 06bfd2aa75
11 changed files with 506 additions and 60 deletions

View File

@@ -1,8 +1,8 @@
import { storage } from '../storage';
import { hashPassword, verifyPassword, generateTempPassword } from '../utils/password';
import { generateTokens, generateVerificationToken, verifyRefreshToken, parseExpiryToSeconds, type TokenPair } from '../utils/jwt';
import { generateTokens, generateVerificationToken, verifyRefreshToken, parseExpiryToSeconds, hashToken, ACCESS_TOKEN_EXPIRY, type TokenPair } from '../utils/jwt';
import { emailService } from './email.service';
import type { RegisterOrganization, LoginRequest, User, UserWithOrganization } from '@shared/schema';
import type { RegisterOrganization, LoginRequest, User, UserWithOrganization, UserSession } from '@shared/schema';
import { db, withSuperAdmin } from '../db';
import { notificationService } from './notification.service';
import { conversations, conversationMembers } from '@shared/schema';
@@ -52,7 +52,7 @@ function isSecureCookie(): boolean {
}
export function setAuthCookies(res: Response, tokens: TokenPair, remember: boolean): void {
const accessMaxAge = parseExpiryToSeconds(process.env.JWT_ACCESS_EXPIRES || '30d') * 1000;
const accessMaxAge = parseExpiryToSeconds(ACCESS_TOKEN_EXPIRY) * 1000;
const refreshMaxAge = (remember ? REMEMBER_LIFETIME_DAYS : SESSION_LIFETIME_DAYS) * 24 * 60 * 60 * 1000;
res.cookie(ACCESS_TOKEN_COOKIE, tokens.accessToken, {
@@ -166,9 +166,10 @@ export class AuthService {
appRole: user.appRole
}, remember, familyId);
// Шаг 0.6: в БД сохраняется только sha256-хэш refresh-токена, plain не хранится.
await storage.createUserSession({
userId: user.id,
refreshToken: tokens.refreshToken,
refreshTokenHash: hashToken(tokens.refreshToken),
familyId,
familyCreatedAt,
expiresAt,
@@ -294,6 +295,41 @@ export class AuthService {
});
}
// Ротация refresh-токена внутри family: текущая сессия помечается заменённой,
// создаётся новая с новой парой токенов. expiresAt ограничен потолком family.
// В БД пишутся только sha256-хэши токенов (шаг 0.6).
private async rotateFamilySession(
session: UserSession,
user: UserWithOrganization
): Promise<{ tokens: TokenPair; remember: boolean }> {
const remember = session.remember ?? false;
const tokenLifetimeDays = remember ? REMEMBER_LIFETIME_DAYS : SESSION_LIFETIME_DAYS;
const msLifetime = tokenLifetimeDays * 24 * 60 * 60 * 1000;
const familyCreatedAt = new Date(session.familyCreatedAt).getTime();
const maxExpiresAt = new Date(familyCreatedAt + msLifetime);
const proposedExpiresAt = new Date(Date.now() + msLifetime);
const expiresAt = proposedExpiresAt < maxExpiresAt ? proposedExpiresAt : maxExpiresAt;
const tokens = generateTokens({
userId: user.id,
organizationId: user.organizationId,
appRole: user.appRole
}, remember, session.familyId);
await storage.markSessionReplaced(session.id, tokens.refreshToken);
await storage.createUserSession({
userId: user.id,
refreshTokenHash: hashToken(tokens.refreshToken),
familyId: session.familyId,
familyCreatedAt: session.familyCreatedAt,
parentRefreshTokenHash: session.refreshTokenHash,
expiresAt,
remember,
});
return { tokens, remember };
}
async refreshToken(refreshToken: string): Promise<AuthResponse> {
return withSuperAdmin(async () => {
try {
@@ -304,28 +340,20 @@ export class AuthService {
const rotatedSession = await storage.getUserSessionByParentToken(refreshToken);
if (rotatedSession && !rotatedSession.isRevoked) {
const rotatedAt = rotatedSession.createdAt ? new Date(rotatedSession.createdAt).getTime() : 0;
// Within grace period: return the current active tokens of the same family.
// Within grace period: rotate the current active session of the same family.
// Plain-токены в БД не хранятся (шаг 0.6), поэтому вернуть «тот же» refresh
// нельзя — вместо этого активная сессия ротируется и клиент получает новую пару.
if (Date.now() - rotatedAt < RACE_TOLERANCE_MS) {
const activeSession = await storage.getActiveSessionInFamily(rotatedSession.familyId);
if (activeSession) {
const activeUser = await storage.getUserWithOrganization(activeSession.userId);
if (activeUser && activeUser.isActive) {
const remember = activeSession.remember ?? false;
const accessToken = generateTokens({
userId: activeUser.id,
organizationId: activeUser.organizationId,
appRole: activeUser.appRole
}, remember, activeSession.familyId).accessToken;
const { tokens, remember } = await this.rotateFamilySession(activeSession, activeUser);
return {
success: true,
user: { ...activeUser, appRole: activeUser.appRole, permissions: await storage.getAppRolePermissions(activeUser.appRole) } as any,
remember,
tokens: {
accessToken,
refreshToken: activeSession.refreshToken,
expiresIn: parseExpiryToSeconds(process.env.JWT_ACCESS_EXPIRES || '30d'),
familyId: activeSession.familyId,
}
tokens,
};
}
}
@@ -368,30 +396,7 @@ export class AuthService {
return { success: false, error: 'Доступ организации заблокирован' };
}
const remember = session.remember ?? false;
const tokenLifetimeDays = remember ? REMEMBER_LIFETIME_DAYS : SESSION_LIFETIME_DAYS;
const msLifetime = tokenLifetimeDays * 24 * 60 * 60 * 1000;
const familyCreatedAt = new Date(session.familyCreatedAt).getTime();
const maxExpiresAt = new Date(familyCreatedAt + msLifetime);
const proposedExpiresAt = new Date(Date.now() + msLifetime);
const expiresAt = proposedExpiresAt < maxExpiresAt ? proposedExpiresAt : maxExpiresAt;
const tokens = generateTokens({
userId: user.id,
organizationId: user.organizationId,
appRole: user.appRole
}, remember, session.familyId);
await storage.markSessionReplaced(session.refreshToken, tokens.refreshToken);
await storage.createUserSession({
userId: user.id,
refreshToken: tokens.refreshToken,
familyId: session.familyId,
familyCreatedAt: session.familyCreatedAt,
parentRefreshToken: session.refreshToken,
expiresAt,
remember,
});
const { tokens, remember } = await this.rotateFamilySession(session, user);
const permissions = await storage.getAppRolePermissions(user.appRole);
return { success: true, user: { ...user, appRole: user.appRole, permissions } as any, remember, tokens };