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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user