feat(auth): шаг 0.6 — TTL access 15 минут + хэширование refresh/reset-токенов в БД
Some checks are pending
Branch Check / Type Check & Build (push) Waiting to run
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:
@@ -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
|
||||
|
||||
@@ -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-колонок — отдельная миграция после подтверждения стабильности на проде.
|
||||
|
||||
28
migrations/0082_token_hashes.sql
Normal file
28
migrations/0082_token_hashes.sql
Normal file
@@ -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);
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -65,11 +65,12 @@ export interface IStorage {
|
||||
deleteUserOfflineSubscription(userId: number, formId: number): Promise<void>;
|
||||
|
||||
// User sessions
|
||||
// Токены передаются в plain-виде; хэширование (sha256) — внутри реализации (шаг 0.6).
|
||||
createUserSession(session: InsertUserSession): Promise<UserSession>;
|
||||
getUserSessionByToken(refreshToken: string): Promise<UserSession | undefined>;
|
||||
getUserSessionByParentToken(parentRefreshToken: string): Promise<UserSession | undefined>;
|
||||
getActiveSessionInFamily(familyId: string): Promise<UserSession | undefined>;
|
||||
markSessionReplaced(refreshToken: string, replacedByToken: string): Promise<void>;
|
||||
markSessionReplaced(sessionId: number, replacedByToken: string): Promise<void>;
|
||||
revokeSession(refreshToken: string): Promise<void>;
|
||||
revokeSessionFamily(familyId: string): Promise<void>;
|
||||
revokeAllUserSessions(userId: number): Promise<void>;
|
||||
|
||||
@@ -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<User | undefined> {
|
||||
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<UserSession> {
|
||||
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<void> {
|
||||
async markSessionReplaced(sessionId: number, replacedByToken: string): Promise<void> {
|
||||
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<void> {
|
||||
await db
|
||||
.update(userSessions)
|
||||
.set({ isRevoked: true })
|
||||
.where(eq(userSessions.refreshToken, refreshToken));
|
||||
.where(eq(userSessions.refreshTokenHash, hashToken(refreshToken)));
|
||||
}
|
||||
|
||||
async revokeSessionFamily(familyId: string): Promise<void> {
|
||||
@@ -352,7 +361,7 @@ export class UsersStorage {
|
||||
|
||||
/** Physical deletion kept for cleanup operations. */
|
||||
async deleteUserSession(refreshToken: string): Promise<void> {
|
||||
await db.delete(userSessions).where(eq(userSessions.refreshToken, refreshToken));
|
||||
await db.delete(userSessions).where(eq(userSessions.refreshTokenHash, hashToken(refreshToken)));
|
||||
}
|
||||
|
||||
async deleteUserSessions(userId: number): Promise<void> {
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<User, 'passwordHash' | 'verificationToken' | 'resetPasswordToken' | 'resetPasswordExpires'>;
|
||||
export type SafeUser = Omit<User, 'passwordHash' | 'verificationToken' | 'resetPasswordToken' | 'resetPasswordTokenHash' | 'resetPasswordExpires'>;
|
||||
export type UserSession = typeof userSessions.$inferSelect;
|
||||
export type Invitation = typeof invitations.$inferSelect;
|
||||
export type Form = typeof forms.$inferSelect;
|
||||
|
||||
361
tests/token-security.test.ts
Normal file
361
tests/token-security.test.ts
Normal file
@@ -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<string, unknown> = {}) {
|
||||
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<string, unknown> = {}) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user