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