diff --git a/.env.example b/.env.example index 2d8d031..e0e3b58 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,9 @@ JWT_SUPERADMIN_SECRET=your-superadmin-secret-here # JWT_BOT_SECRET=your-bot-secret-here # Token expiry (optional — these are the defaults) -# JWT_ACCESS_EXPIRES=30d +# Access-токен короткоживущий (15 минут, шаг 0.6): клиент обновляет его через +# /api/auth/refresh по httpOnly refresh-cookie. Увеличивать только осознанно. +# JWT_ACCESS_EXPIRES=15m # JWT_REFRESH_EXPIRES_REMEMBER=90d # JWT_REFRESH_EXPIRES_SESSION=30d # JWT_SUPERADMIN_EXPIRES=1h diff --git a/IMPLEMENTATION_LOG.md b/IMPLEMENTATION_LOG.md index e71b6d5..78f0f27 100644 --- a/IMPLEMENTATION_LOG.md +++ b/IMPLEMENTATION_LOG.md @@ -442,3 +442,26 @@ - Как проверялось: на проде подтверждено включение (pg_class relrowsecurity+relforcerowsecurity=31), smoke через API; локально — полный цикл на копии прод-БД. - Влияние на поиск/UX: нет. - Подводные камни: при переносе на новый сервер — сначала ENABLE_RLS=true в .env, иначе app не поднимется (это и есть цель шага). + +--- + +## [0.6] TTL access-токена 15 минут + хэширование refresh/reset-токенов в БД + +- Статус: ⏸ частично — код готов, ждёт окно деплоя (разлогин всех) +- Зачем: украденный access-токен жил до 30 дней; refresh/reset-токены лежали в БД в открытом виде — при утечке БД атакующий получал вечный доступ (аудит, Фаза 0). +- Что изменено: + - `server/utils/jwt.ts` — дефолт `JWT_ACCESS_EXPIRES` '30d' → '15m' (env-переопределение сохранено), добавлен `hashToken()` (sha256 hex) и экспорт `ACCESS_TOKEN_EXPIRY`. + - Миграция `migrations/0082_token_hashes.sql` — колонки `refresh_token_hash` (NOT NULL, UNIQUE), `parent_refresh_token_hash`, `replaced_by_token_hash` в `user_sessions`; `reset_password_token_hash` в `users`. **`DELETE FROM user_sessions` + очистка reset-токенов — глобальный разлогин при деплое.** Legacy plain-колонки (`refresh_token`, `parent_refresh_token`, `replaced_by_token`, `reset_password_token`) НЕ удалены (отдельная миграция позже), но перестали писаться; у `refresh_token` снят NOT NULL. + - `shared/schema.ts` — новые колонки в `userSessions`/`users`; `resetPasswordTokenHash` добавлен в исключения `SafeUser`/`safeUserColumns`. + - `server/storage/users.storage.ts` + интерфейс `server/storage.ts` — все lookup/отзыв сессий (`getUserSessionByToken`, `getUserSessionByParentToken`, `revokeSession`, `deleteUserSession`) и `getUserByResetPasswordToken` ищут по `hashToken(plain)`; `createUserSession` принудительно обнуляет plain-колонки; `markSessionReplaced(sessionId, newToken)` — по id сессии (plain старого токена больше недоступен). + - `server/services/auth.service.ts` — `createSession` и ротация пишут только хэши; ротация вынесена в `rotateFamilySession()`. Grace-period (гонка вкладок, 5 мин): раньше клиенту возвращался plain-токен активной сессии из БД — теперь он не хранится, поэтому активная сессия ротируется и клиент получает новую пару (поведение сходится, сессий чуть больше). Cookie access Max-Age берётся из `ACCESS_TOKEN_EXPIRY`. + - `server/routes/auth.core.routes.ts` — forgot-password пишет `resetPasswordTokenHash` (+ `resetPasswordToken: null`), plain только в письме; reset-password очищает обе колонки. + - `.env.example`, `server/swagger.ts` — дефолты/описания приведены к факту (access 15m, refresh 30d/90d). + - `tests/token-security.test.ts` — 12 тестов: hashToken, выпуск по хэшу (plain не уходит в storage), ротация с parent-хэшем, невалидный токен, reuse detection → revokeSessionFamily, grace-ротация, logout, reset по хэшу/очистка/истёкший/невалидный. +- Как проверялось: `npm run check` чисто (оба tsc); `npx vitest run` — 118/118; `npm run lint` — 0 errors; `npm run build` — собирается. Клиент не тронут: refresh реактивный по 401 (singleton `refreshSession` в queryClient + useOfflineSync), таймеров, завязанных на 30d, нет. +- Влияние на поиск/UX: нет (только auth-контур). +- Подводные камни: + - **Деплой разлогинит всех** (миграция чистит `user_sessions`) — деплоить в окно согласованного простоя; после деплоя проверить re-login и refresh-ротацию вручную. + - JWT детерминирован по (payload, iat в секундах): две ротации одной и той же сессии в пределах одной секунды выдадут одинаковый токен — безвредно (хэш и семантика совпадают). + - `bot_sessions` (refresh-токены bot_login) сознательно не тронуты — отдельная модель audience `workflow-bots`; кандидат на аналогичное хэширование отдельным шагом. In-memory `resetTokenStore` суперадмина — не в БД, вне скоупа. + - Удаление legacy plain-колонок — отдельная миграция после подтверждения стабильности на проде. diff --git a/migrations/0082_token_hashes.sql b/migrations/0082_token_hashes.sql new file mode 100644 index 0000000..5652b79 --- /dev/null +++ b/migrations/0082_token_hashes.sql @@ -0,0 +1,28 @@ +-- Шаг 0.6 (production-готовность): хэширование refresh/reset-токенов в БД. +-- Plain-токены больше не хранятся: в БД пишется только sha256-хэш (hex, 64 символа). +-- Refresh-сессии живут в user_sessions, reset-токены — в users (отдельных таблиц нет). +-- +-- ВАЖНО: миграция инвалидирует ВСЕ существующие сессии и reset-ссылки +-- (глобальный разлогин). Legacy-колонки с plain-токенами НЕ удаляются +-- (отложено на отдельную миграцию), но больше не пишутся. + +ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS refresh_token_hash VARCHAR(64); +ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS parent_refresh_token_hash VARCHAR(64); +ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS replaced_by_token_hash VARCHAR(64); +ALTER TABLE users ADD COLUMN IF NOT EXISTS reset_password_token_hash VARCHAR(64); + +-- Глобальный разлогин: старые строки содержат plain-токены и без хэша бесполезны. +DELETE FROM user_sessions; +UPDATE users SET reset_password_token = NULL, reset_password_expires = NULL + WHERE reset_password_token IS NOT NULL; + +-- refresh_token больше не пишется — снимаем NOT NULL (саму колонку оставляем). +ALTER TABLE user_sessions ALTER COLUMN refresh_token DROP NOT NULL; +ALTER TABLE user_sessions ALTER COLUMN refresh_token_hash SET NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS user_sessions_refresh_token_hash_key + ON user_sessions (refresh_token_hash); +CREATE INDEX IF NOT EXISTS user_sessions_parent_refresh_token_hash_idx + ON user_sessions (parent_refresh_token_hash); +CREATE INDEX IF NOT EXISTS users_reset_password_token_hash_idx + ON users (reset_password_token_hash); diff --git a/server/routes/auth.core.routes.ts b/server/routes/auth.core.routes.ts index 2c9231a..bccb1f4 100644 --- a/server/routes/auth.core.routes.ts +++ b/server/routes/auth.core.routes.ts @@ -7,7 +7,7 @@ import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth import { validateRequest } from "../middleware/validation.middleware"; import { registerOrganizationSchema, loginSchema, registerByInvitationSchema } from "@shared/schema"; import { hashPassword, verifyPassword } from "../utils/password"; -import { generateTokens } from "../utils/jwt"; +import { hashToken } from "../utils/jwt"; import { emailService } from "../services/email.service"; import { notificationService } from "../services/notification.service"; import { authLimiter, refreshLimiter } from "./shared"; @@ -339,8 +339,10 @@ export function registerAuthCoreRoutes(router: Router): void { const token = crypto.randomBytes(32).toString('hex'); const expires = new Date(Date.now() + 60 * 60 * 1000); + // Шаг 0.6: в БД сохраняется только sha256-хэш токена, plain — только в письме. await storage.updateUser(user.id, { - resetPasswordToken: token, + resetPasswordToken: null, + resetPasswordTokenHash: hashToken(token), resetPasswordExpires: expires, }); @@ -381,7 +383,7 @@ export function registerAuthCoreRoutes(router: Router): void { } if (!user.resetPasswordExpires || new Date() > new Date(user.resetPasswordExpires)) { - await storage.updateUser(user.id, { resetPasswordToken: null, resetPasswordExpires: null }); + await storage.updateUser(user.id, { resetPasswordToken: null, resetPasswordTokenHash: null, resetPasswordExpires: null }); return res.status(400).json({ success: false, error: 'Срок действия ссылки истёк. Запросите новую.' }); } @@ -389,6 +391,7 @@ export function registerAuthCoreRoutes(router: Router): void { await storage.updateUser(user.id, { passwordHash, resetPasswordToken: null, + resetPasswordTokenHash: null, resetPasswordExpires: null, }); diff --git a/server/services/auth.service.ts b/server/services/auth.service.ts index 19de86a..131acac 100644 --- a/server/services/auth.service.ts +++ b/server/services/auth.service.ts @@ -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 { 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 }; diff --git a/server/storage.ts b/server/storage.ts index ac370a1..56c9e83 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -65,11 +65,12 @@ export interface IStorage { deleteUserOfflineSubscription(userId: number, formId: number): Promise; // User sessions + // Токены передаются в plain-виде; хэширование (sha256) — внутри реализации (шаг 0.6). createUserSession(session: InsertUserSession): Promise; getUserSessionByToken(refreshToken: string): Promise; getUserSessionByParentToken(parentRefreshToken: string): Promise; getActiveSessionInFamily(familyId: string): Promise; - markSessionReplaced(refreshToken: string, replacedByToken: string): Promise; + markSessionReplaced(sessionId: number, replacedByToken: string): Promise; revokeSession(refreshToken: string): Promise; revokeSessionFamily(familyId: string): Promise; revokeAllUserSessions(userId: number): Promise; diff --git a/server/storage/users.storage.ts b/server/storage/users.storage.ts index 657fd59..85a769a 100644 --- a/server/storage/users.storage.ts +++ b/server/storage/users.storage.ts @@ -2,6 +2,7 @@ import { users, organizations, userSessions, invitations, userCustomFields, user import { db } from "../db"; import { eq, and, desc, asc, sql, ilike, or, gte, lte, inArray, isNull } from "drizzle-orm"; import crypto from "crypto"; +import { hashToken } from "../utils/jwt"; import { userStatusesStorage } from "./user-statuses.storage"; import { invalidateAccessibleTasksForUser } from "../utils/cache"; @@ -72,7 +73,8 @@ export class UsersStorage { } async getUserByResetPasswordToken(token: string): Promise { - const [user] = await db.select().from(users).where(eq(users.resetPasswordToken, token)); + // Шаг 0.6: поиск по sha256-хэшу, plain-токен в БД не хранится. + const [user] = await db.select().from(users).where(eq(users.resetPasswordTokenHash, hashToken(token))); return user || undefined; } @@ -283,10 +285,17 @@ export class UsersStorage { } // User sessions + // Шаг 0.6: в БД хранятся только sha256-хэши токенов (refresh_token_hash и т.д.); + // plain-колонки (refresh_token, parent_refresh_token, replaced_by_token) не пишутся. async createUserSession(session: InsertUserSession): Promise { const [userSession] = await db .insert(userSessions) - .values(session) + .values({ + ...session, + refreshToken: null, + parentRefreshToken: null, + replacedByToken: null, + }) .returning(); return userSession; } @@ -295,7 +304,7 @@ export class UsersStorage { const [session] = await db .select() .from(userSessions) - .where(eq(userSessions.refreshToken, refreshToken)); + .where(eq(userSessions.refreshTokenHash, hashToken(refreshToken))); return session || undefined; } @@ -303,7 +312,7 @@ export class UsersStorage { const [session] = await db .select() .from(userSessions) - .where(eq(userSessions.parentRefreshToken, parentRefreshToken)); + .where(eq(userSessions.parentRefreshTokenHash, hashToken(parentRefreshToken))); return session || undefined; } @@ -322,18 +331,18 @@ export class UsersStorage { return session || undefined; } - async markSessionReplaced(refreshToken: string, replacedByToken: string): Promise { + async markSessionReplaced(sessionId: number, replacedByToken: string): Promise { await db .update(userSessions) - .set({ isReplaced: true, replacedByToken }) - .where(eq(userSessions.refreshToken, refreshToken)); + .set({ isReplaced: true, replacedByTokenHash: hashToken(replacedByToken), replacedByToken: null }) + .where(eq(userSessions.id, sessionId)); } async revokeSession(refreshToken: string): Promise { await db .update(userSessions) .set({ isRevoked: true }) - .where(eq(userSessions.refreshToken, refreshToken)); + .where(eq(userSessions.refreshTokenHash, hashToken(refreshToken))); } async revokeSessionFamily(familyId: string): Promise { @@ -352,7 +361,7 @@ export class UsersStorage { /** Physical deletion kept for cleanup operations. */ async deleteUserSession(refreshToken: string): Promise { - await db.delete(userSessions).where(eq(userSessions.refreshToken, refreshToken)); + await db.delete(userSessions).where(eq(userSessions.refreshTokenHash, hashToken(refreshToken))); } async deleteUserSessions(userId: number): Promise { diff --git a/server/swagger.ts b/server/swagger.ts index f06e0d9..9f5fdf2 100644 --- a/server/swagger.ts +++ b/server/swagger.ts @@ -199,7 +199,7 @@ const modelSchemas = { type: "object", properties: { accessToken: { type: "string", description: "JWT access token (15 min)" }, - refreshToken: { type: "string", description: "JWT refresh token (7 days)" }, + refreshToken: { type: "string", description: "JWT refresh token (30 days session / 90 days remember)" }, expiresIn: { type: "integer", description: "Access token TTL in seconds" }, }, }, diff --git a/server/utils/jwt.ts b/server/utils/jwt.ts index b605fa6..92d346e 100644 --- a/server/utils/jwt.ts +++ b/server/utils/jwt.ts @@ -31,7 +31,8 @@ const REFRESH_TOKEN_SECRET = requireSecret('JWT_REFRESH_SECRET'); const SUPERADMIN_TOKEN_SECRET = requireSecret('JWT_SUPERADMIN_SECRET'); const BOT_TOKEN_SECRET = process.env.JWT_BOT_SECRET || ACCESS_TOKEN_SECRET; -const ACCESS_TOKEN_EXPIRY = process.env.JWT_ACCESS_EXPIRES || '30d'; +// Шаг 0.6: дефолт access-токена — 15 минут (было 30d). Env-переопределение сохранено. +export const ACCESS_TOKEN_EXPIRY = process.env.JWT_ACCESS_EXPIRES || '15m'; const REFRESH_TOKEN_EXPIRY_REMEMBER = process.env.JWT_REFRESH_EXPIRES_REMEMBER || '90d'; const REFRESH_TOKEN_EXPIRY_SESSION = process.env.JWT_REFRESH_EXPIRES_SESSION || '30d'; const SUPERADMIN_TOKEN_EXPIRY = process.env.JWT_SUPERADMIN_EXPIRES || '1h'; @@ -139,6 +140,12 @@ export function generateResetToken(): string { return crypto.randomBytes(32).toString('hex'); } +// sha256-хэш одноразовых/долгоживущих токенов для хранения в БД (шаг 0.6). +// Plain-токены refresh/password-reset в БД не хранятся — только этот хэш. +export function hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} + export function generateBotServiceToken(botId: number, organizationId: number): string { const payload: BotServiceTokenPayload = { botId, diff --git a/shared/schema.ts b/shared/schema.ts index 9108d56..8cd1728 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -129,6 +129,7 @@ export const users = pgTable("users", { emailVerified: boolean("email_verified").default(false), verificationToken: varchar("verification_token", { length: 255 }), resetPasswordToken: varchar("reset_password_token", { length: 255 }), + resetPasswordTokenHash: varchar("reset_password_token_hash", { length: 64 }), resetPasswordExpires: timestamp("reset_password_expires"), lastLogin: timestamp("last_login"), lastActivityAt: timestamp("last_activity_at"), @@ -142,7 +143,7 @@ export const users = pgTable("users", { // Колонки users без чувствительных данных (хэш пароля, токены подтверждения и сброса). // Использовать во всех массовых/клиентских выборках пользователей. -const { passwordHash: _ph, verificationToken: _vt, resetPasswordToken: _rpt, resetPasswordExpires: _rpe, ...restSafeUserColumns } = getTableColumns(users); +const { passwordHash: _ph, verificationToken: _vt, resetPasswordToken: _rpt, resetPasswordTokenHash: _rpth, resetPasswordExpires: _rpe, ...restSafeUserColumns } = getTableColumns(users); export const safeUserColumns = restSafeUserColumns; export const userStatuses = pgTable("user_statuses", { @@ -185,12 +186,17 @@ export type InsertUserDelegation = typeof userDelegations.$inferInsert; export const userSessions = pgTable("user_sessions", { id: serial("id").primaryKey(), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), - refreshToken: varchar("refresh_token", { length: 500 }).notNull().unique(), + // Шаг 0.6: plain refresh-токены в БД не хранятся — только sha256-хэши. + // refresh_token/parent_refresh_token/replaced_by_token — legacy-колонки (не пишутся, удаление отложено). + refreshToken: varchar("refresh_token", { length: 500 }).unique(), + refreshTokenHash: varchar("refresh_token_hash", { length: 64 }).notNull().unique("user_sessions_refresh_token_hash_key"), familyId: uuid("family_id").notNull(), familyCreatedAt: timestamp("family_created_at").notNull(), parentRefreshToken: varchar("parent_refresh_token", { length: 500 }), + parentRefreshTokenHash: varchar("parent_refresh_token_hash", { length: 64 }), isReplaced: boolean("is_replaced").notNull().default(false), replacedByToken: varchar("replaced_by_token", { length: 500 }), + replacedByTokenHash: varchar("replaced_by_token_hash", { length: 64 }), isRevoked: boolean("is_revoked").notNull().default(false), expiresAt: timestamp("expires_at").notNull(), remember: boolean("remember").notNull().default(false), @@ -201,6 +207,7 @@ export const userSessions = pgTable("user_sessions", { userIdIdx: index("user_sessions_user_id_idx").on(table.userId), familyIdIdx: index("user_sessions_family_id_idx").on(table.familyId), parentRefreshTokenIdx: index("user_sessions_parent_refresh_token_idx").on(table.parentRefreshToken), + parentRefreshTokenHashIdx: index("user_sessions_parent_refresh_token_hash_idx").on(table.parentRefreshTokenHash), })); // Invitations table - for inviting users to organization @@ -1372,7 +1379,7 @@ export const searchUsersSchema = z.object({ export type Organization = typeof organizations.$inferSelect; export type User = typeof users.$inferSelect; // Пользователь без чувствительных полей — безопасен для массовой выдачи клиенту -export type SafeUser = Omit; +export type SafeUser = Omit; export type UserSession = typeof userSessions.$inferSelect; export type Invitation = typeof invitations.$inferSelect; export type Form = typeof forms.$inferSelect; diff --git a/tests/token-security.test.ts b/tests/token-security.test.ts new file mode 100644 index 0000000..2dca2d2 --- /dev/null +++ b/tests/token-security.test.ts @@ -0,0 +1,361 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import crypto from 'crypto'; + +const mockStorage = vi.hoisted(() => ({ + getUserByEmail: vi.fn(), + getUserByEmailAndSlug: vi.fn(), + getUserWithOrganization: vi.fn(), + getUser: vi.fn(), + updateUser: vi.fn(), + createUserSession: vi.fn(), + getUserSessionByToken: vi.fn(), + getUserSessionByParentToken: vi.fn(), + getActiveSessionInFamily: vi.fn(), + markSessionReplaced: vi.fn(), + revokeSession: vi.fn(), + revokeSessionFamily: vi.fn(), + revokeAllUserSessions: vi.fn(), + getAppRolePermissions: vi.fn(), + getOrganization: vi.fn(), + getUserByResetPasswordToken: vi.fn(), +})); + +const mockEmailService = vi.hoisted(() => ({ + sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../server/db', () => ({ + db: {}, + pool: { query: vi.fn().mockResolvedValue({ rows: [] }) }, + withTenant: (_orgId: number, fn: () => unknown) => fn(), + withSuperAdmin: (fn: (db: unknown) => unknown) => fn({}), + openTenantCtx: vi.fn().mockResolvedValue({ + run: (fn: () => void) => fn(), + release: vi.fn(), + }), + openSuperAdminCtx: vi.fn().mockResolvedValue(undefined), + _tenantCtx: { getStore: vi.fn().mockReturnValue(null) }, +})); + +vi.mock('../server/storage', () => ({ storage: mockStorage })); + +// bcrypt замокан ради скорости тестов: хэш = 'hash:<пароль>'. +vi.mock('../server/utils/password', () => ({ + hashPassword: vi.fn(async (p: string) => `hash:${p}`), + verifyPassword: vi.fn(async (p: string, h: string) => h === `hash:${p}`), + generateTempPassword: vi.fn(() => 'Temp1234!'), +})); + +vi.mock('../server/services/email.service', () => ({ + emailService: mockEmailService, + EmailService: class {}, +})); + +vi.mock('../server/services/notification.service', () => ({ + notificationService: { + emit: vi.fn(), + on: vi.fn(), + sendNotification: vi.fn().mockResolvedValue(undefined), + createDefaultSubscriptions: vi.fn().mockResolvedValue(undefined), + processEvent: vi.fn(), + }, + EVENT_TYPES: {}, +})); + +vi.mock('../server/routes/shared', () => ({ + authLimiter: (_req: unknown, _res: unknown, next: () => void) => next(), + refreshLimiter: (_req: unknown, _res: unknown, next: () => void) => next(), +})); + +vi.mock('../server/utils/audit', () => ({ + logAudit: vi.fn().mockResolvedValue(undefined), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})); + +import express from 'express'; +import request from 'supertest'; +import { Router } from 'express'; +import { registerAuthCoreRoutes } from '../server/routes/auth.core.routes'; +import { authService } from '../server/services/auth.service'; +import { generateTokens, hashToken } from '../server/utils/jwt'; +import { clearLoginAttempts } from '../server/utils/login-attempts'; + +const PASSWORD = 'Secret123!'; + +function makeUser(overrides: Record = {}) { + return { + id: 1, + organizationId: 1, + email: 'user@example.com', + passwordHash: `hash:${PASSWORD}`, + firstName: 'Иван', + lastName: 'Иванов', + middleName: null, + position: null, + appRole: 'user', + isActive: true, + organization: { + id: 1, + name: 'Тест', + slug: 'test', + displayName: 'Тест', + isActive: true, + }, + ...overrides, + }; +} + +function setupExistingUser(user = makeUser()) { + mockStorage.getUserByEmail.mockResolvedValue(user); + mockStorage.getUserWithOrganization.mockResolvedValue(user); + mockStorage.getAppRolePermissions.mockResolvedValue([]); + mockStorage.updateUser.mockResolvedValue(user); + mockStorage.createUserSession.mockResolvedValue({}); + return user; +} + +function makeSession(plainRefreshToken: string, overrides: Record = {}) { + return { + id: 10, + userId: 1, + refreshToken: null, + refreshTokenHash: hashToken(plainRefreshToken), + familyId: 'family-1', + familyCreatedAt: new Date(), + parentRefreshToken: null, + parentRefreshTokenHash: null, + isReplaced: false, + isRevoked: false, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + remember: false, + createdAt: new Date(), + ...overrides, + }; +} + +function buildApp() { + const app = express(); + app.use(express.json()); + const router = Router(); + registerAuthCoreRoutes(router); + app.use(router); + return app; +} + +describe('hashToken', () => { + it('возвращает детерминированный sha256-хэш в hex (64 символа)', () => { + const token = 'some-refresh-token'; + const expected = crypto.createHash('sha256').update(token).digest('hex'); + expect(hashToken(token)).toBe(expected); + expect(hashToken(token)).toMatch(/^[0-9a-f]{64}$/); + expect(hashToken(token)).toBe(hashToken(token)); + expect(hashToken(token)).not.toBe(hashToken(token + 'x')); + }); +}); + +describe('refresh-токены: выпуск по хэшу (plain не сохраняется)', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearLoginAttempts(); + }); + + it('при логине в БД уходит только refreshTokenHash, plain refreshToken не передаётся', async () => { + const app = buildApp(); + setupExistingUser(); + + const res = await request(app) + .post('/api/auth/login') + .send({ email: 'user@example.com', password: PASSWORD }); + + expect(res.status).toBe(200); + const plainRefresh: string = res.body.tokens.refreshToken; + expect(plainRefresh).toBeTruthy(); + + const sessionArg = mockStorage.createUserSession.mock.calls[0][0]; + expect(sessionArg.refreshTokenHash).toBe(hashToken(plainRefresh)); + expect(sessionArg.refreshToken).toBeUndefined(); + }); + + it('refresh по валидному токену: ротация с хэшами, plain в createUserSession не уходит', async () => { + const user = setupExistingUser(); + const { refreshToken: oldPlain } = generateTokens({ userId: 1, organizationId: 1, appRole: 'user' }, false, 'family-1'); + const session = makeSession(oldPlain); + + mockStorage.getUserSessionByToken.mockResolvedValue(session); + mockStorage.markSessionReplaced.mockResolvedValue(undefined); + + const result = await authService.refreshToken(oldPlain); + + expect(result.success).toBe(true); + expect(result.tokens?.refreshToken).toBeTruthy(); + // NB: JWT с одинаковым payload и iat (в секундах) детерминирован — при ротации + // в ту же секунду новый токен может совпасть со старым; это свойство JWT, не логики. + + // Поиск сессии шёл по переданному plain-токену (хэширование — внутри storage) + expect(mockStorage.getUserSessionByToken).toHaveBeenCalledWith(oldPlain); + + // Старая сессия помечена заменённой по id, новый токен передан plain (хэширует storage) + const newPlain = result.tokens!.refreshToken; + expect(mockStorage.markSessionReplaced).toHaveBeenCalledWith(session.id, newPlain); + + // Новая сессия: только хэши, связь с родителем — по хэшу старого токена + const newSessionArg = mockStorage.createUserSession.mock.calls.find( + (c) => c[0].parentRefreshTokenHash, + )?.[0]; + expect(newSessionArg).toBeTruthy(); + expect(newSessionArg.refreshTokenHash).toBe(hashToken(newPlain)); + expect(newSessionArg.parentRefreshTokenHash).toBe(hashToken(oldPlain)); + expect(newSessionArg.refreshToken).toBeUndefined(); + expect(newSessionArg.parentRefreshToken).toBeUndefined(); + expect(newSessionArg.familyId).toBe('family-1'); + expect(user.id).toBe(1); + }); + + it('невалидный токен не находится: ни как текущий, ни как родительский', async () => { + setupExistingUser(); + mockStorage.getUserSessionByToken.mockResolvedValue(undefined); + mockStorage.getUserSessionByParentToken.mockResolvedValue(undefined); + + const result = await authService.refreshToken('forged-token'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Недействительный'); + expect(mockStorage.markSessionReplaced).not.toHaveBeenCalled(); + expect(mockStorage.createUserSession).not.toHaveBeenCalled(); + }); + + it('reuse detection: отозванная/старая rotated-сессия → отзыв всей family', async () => { + setupExistingUser(); + mockStorage.getUserSessionByToken.mockResolvedValue(undefined); + // Rotated-сессия старая (grace period 5 минут истёк) или отозванная + const rotated = makeSession('old-plain', { + isRevoked: false, + createdAt: new Date(Date.now() - 10 * 60 * 1000), + }); + mockStorage.getUserSessionByParentToken.mockResolvedValue(rotated); + mockStorage.revokeSessionFamily.mockResolvedValue(undefined); + + const result = await authService.refreshToken('old-plain'); + + expect(result.success).toBe(false); + expect(mockStorage.revokeSessionFamily).toHaveBeenCalledWith('family-1'); + }); + + it('grace period: свежая rotated-сессия → активная сессия family ротируется, клиент получает новую пару', async () => { + setupExistingUser(); + const { refreshToken: activePlain } = generateTokens({ userId: 1, organizationId: 1, appRole: 'user' }, false, 'family-1'); + const activeSession = makeSession(activePlain, { id: 11 }); + + mockStorage.getUserSessionByToken.mockResolvedValue(undefined); + mockStorage.getUserSessionByParentToken.mockResolvedValue( + makeSession('stale-plain', { createdAt: new Date() }), + ); + mockStorage.getActiveSessionInFamily.mockResolvedValue(activeSession); + mockStorage.markSessionReplaced.mockResolvedValue(undefined); + + const result = await authService.refreshToken('stale-plain'); + + expect(result.success).toBe(true); + // Plain-токен активной сессии в БД не хранится — выдаётся пара через ротацию + expect(result.tokens?.refreshToken).toBeTruthy(); + expect(mockStorage.markSessionReplaced).toHaveBeenCalledWith(activeSession.id, result.tokens!.refreshToken); + }); + + it('отзыв сессии при logout: revokeSession вызывается с plain-токеном (хэширует storage)', async () => { + mockStorage.revokeSession.mockResolvedValue(undefined); + + await authService.logout('some-refresh-token'); + + expect(mockStorage.revokeSession).toHaveBeenCalledWith('some-refresh-token'); + }); +}); + +describe('reset-токены по хэшу', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearLoginAttempts(); + }); + + it('forgot-password: в БД уходит resetPasswordTokenHash и resetPasswordToken=null, plain — только в письмо', async () => { + const app = buildApp(); + setupExistingUser(); + mockStorage.getOrganization.mockResolvedValue({ id: 1, name: 'Тест', displayName: 'Тест' }); + + const res = await request(app) + .post('/api/auth/forgot-password') + .send({ email: 'user@example.com' }); + + expect(res.status).toBe(200); + + // Plain-токен ушёл в письмо + const emailedToken: string = mockEmailService.sendPasswordResetEmail.mock.calls[0][2]; + expect(emailedToken).toMatch(/^[0-9a-f]{64}$/); + + // В БД — только хэш, plain-колонка явно обнулена + const updateArg = mockStorage.updateUser.mock.calls.find( + (c) => c[1].resetPasswordTokenHash, + )?.[1]; + expect(updateArg).toBeTruthy(); + expect(updateArg.resetPasswordTokenHash).toBe(hashToken(emailedToken)); + expect(updateArg.resetPasswordToken).toBeNull(); + expect(updateArg.resetPasswordExpires).toBeInstanceOf(Date); + }); + + it('reset-password: поиск пользователя по токену из ссылки, после сброса хэш очищается', async () => { + const app = buildApp(); + const user = makeUser({ resetPasswordExpires: new Date(Date.now() + 60 * 60 * 1000) }); + mockStorage.getUserByResetPasswordToken.mockResolvedValue(user); + mockStorage.revokeAllUserSessions.mockResolvedValue(undefined); + + const res = await request(app) + .post('/api/auth/reset-password') + .send({ token: 'a'.repeat(64), password: 'NewSecret123!' }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + // Токен из ссылки передан в storage как есть — поиск по хэшу внутри storage + expect(mockStorage.getUserByResetPasswordToken).toHaveBeenCalledWith('a'.repeat(64)); + + const updateArg = mockStorage.updateUser.mock.calls.find( + (c) => c[1].passwordHash, + )?.[1]; + expect(updateArg.passwordHash).toBe('hash:NewSecret123!'); + expect(updateArg.resetPasswordToken).toBeNull(); + expect(updateArg.resetPasswordTokenHash).toBeNull(); + expect(updateArg.resetPasswordExpires).toBeNull(); + + // Все сессии пользователя инвалидируются + expect(mockStorage.revokeAllUserSessions).toHaveBeenCalledWith(user.id); + }); + + it('reset-password: невалидный токен не находится → 400', async () => { + const app = buildApp(); + mockStorage.getUserByResetPasswordToken.mockResolvedValue(undefined); + + const res = await request(app) + .post('/api/auth/reset-password') + .send({ token: 'b'.repeat(64), password: 'NewSecret123!' }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('Неверная или устаревшая ссылка'); + expect(mockStorage.updateUser).not.toHaveBeenCalled(); + }); + + it('reset-password: истёкший токен → 400, хэш очищается', async () => { + const app = buildApp(); + const user = makeUser({ resetPasswordExpires: new Date(Date.now() - 1000) }); + mockStorage.getUserByResetPasswordToken.mockResolvedValue(user); + + const res = await request(app) + .post('/api/auth/reset-password') + .send({ token: 'c'.repeat(64), password: 'NewSecret123!' }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('истёк'); + + const updateArg = mockStorage.updateUser.mock.calls[0][1]; + expect(updateArg.resetPasswordTokenHash).toBeNull(); + expect(updateArg.resetPasswordToken).toBeNull(); + }); +});