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

@@ -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> {