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

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