Введение: почему OAuth 2.0 и JWT — стандарт современной авторизации
В современной веб-разработке безопасная аутентификация и авторизация — критически важные компоненты любого приложения. OAuth 2.0 и JWT (JSON Web Tokens) стали де-факто стандартом для построения распределенных систем. OAuth 2.0 предоставляет протокол делегированного доступа, а JWT — компактный и самодостаточный формат токена.
В этой статье мы на практических примерах на TypeScript разберем, как реализовать полный цикл авторизации: от выдачи токенов до их валидации и обновления. Вы узнаете, как защитить API-эндпоинты, реализовать refresh-токены и обработать типовые ошибки.
1. Базовая настройка проекта и установка зависимостей
Для начала создадим проект на Node.js с TypeScript. Убедитесь, что у вас установлены Node.js (версия 18+) и npm.
mkdir oauth-jwt-typescript
cd oauth-jwt-typescript
npm init -y
npm install express cors dotenv jsonwebtoken bcrypt
npm install -D typescript @types/express @types/cors @types/jsonwebtoken @types/bcrypt ts-node nodemon
npx tsc --init
Настроим tsconfig.json для работы с ES-модулями и строгими типами. В файл .env добавим секретные ключи:
ACCESS_TOKEN_SECRET=your-super-secret-access-key
REFRESH_TOKEN_SECRET=your-super-secret-refresh-key
ACCESS_TOKEN_EXPIRES_IN=15m
REFRESH_TOKEN_EXPIRES_IN=7d
2. Реализация генерации и верификации JWT токенов
Создадим утилиту src/utils/jwt.ts для работы с токенами. Мы будем использовать два типа токенов: Access Token (короткоживущий) и Refresh Token (долгоживущий).
import jwt from 'jsonwebtoken';
import dotenv from 'dotenv';
dotenv.config();
interface TokenPayload {
userId: string;
email: string;
role: 'user' | 'admin';
}
export const generateAccessToken = (payload: TokenPayload): string => {
return jwt.sign(payload, process.env.ACCESS_TOKEN_SECRET!, {
expiresIn: process.env.ACCESS_TOKEN_EXPIRES_IN || '15m',
});
};
export const generateRefreshToken = (payload: TokenPayload): string => {
return jwt.sign(payload, process.env.REFRESH_TOKEN_SECRET!, {
expiresIn: process.env.REFRESH_TOKEN_EXPIRES_IN || '7d',
});
};
export const verifyAccessToken = (token: string): TokenPayload => {
return jwt.verify(token, process.env.ACCESS_TOKEN_SECRET!) as TokenPayload;
};
export const verifyRefreshToken = (token: string): TokenPayload => {
return jwt.verify(token, process.env.REFRESH_TOKEN_SECRET!) as TokenPayload;
};
Обратите внимание: мы используем строгую типизацию для payload. Это гарантирует, что в токене всегда будут необходимые поля.
3. Создание middleware для защиты маршрутов
Теперь реализуем middleware, который будет проверять наличие и валидность Access Token в заголовке Authorization. Создадим src/middleware/auth.ts:
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../utils/jwt';
export interface AuthRequest extends Request {
user?: {
userId: string;
email: string;
role: 'user' | 'admin';
};
}
export const authenticate = (req: AuthRequest, res: Response, next: NextFunction): void => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
res.status(401).json({ error: 'Access token is required' });
return;
}
try {
const decoded = verifyAccessToken(token);
req.user = decoded;
next();
} catch (error) {
res.status(403).json({ error: 'Invalid or expired access token' });
}
};
export const authorizeRole = (...roles: string[]) => {
return (req: AuthRequest, res: Response, next: NextFunction): void => {
if (!req.user || !roles.includes(req.user.role)) {
res.status(403).json({ error: 'Insufficient permissions' });
return;
}
next();
};
};
Мы добавили два middleware: authenticate для проверки токена и authorizeRole для разграничения доступа по ролям. Это позволяет гибко настраивать права.
4. Реализация эндпоинтов логина и обновления токенов
Создадим основной роутер src/routes/auth.ts. Здесь мы реализуем вход пользователя (выдачу пары токенов) и обновление Access Token через Refresh Token.
import { Router, Request, Response } from 'express';
import bcrypt from 'bcrypt';
import { generateAccessToken, generateRefreshToken, verifyRefreshToken } from '../utils/jwt';
import { authenticate, AuthRequest } from '../middleware/auth';
const router = Router();
// Хранилище refresh-токенов (в реальном проекте — база данных)
const refreshTokens: string[] = [];
// POST /api/auth/login
router.post('/login', async (req: Request, res: Response) => {
const { email, password } = req.body;
// Имитация поиска пользователя в БД
const user = { userId: '123', email, role: 'user' as const, passwordHash: '$2b$10$...' };
// Проверка пароля
const isValidPassword = await bcrypt.compare(password, user.passwordHash);
if (!isValidPassword) {
res.status(401).json({ error: 'Invalid credentials' });
return;
}
const payload = { userId: user.userId, email: user.email, role: user.role };
const accessToken = generateAccessToken(payload);
const refreshToken = generateRefreshToken(payload);
refreshTokens.push(refreshToken); // Сохраняем refresh токен
res.json({
accessToken,
refreshToken,
tokenType: 'Bearer',
expiresIn: 900, // 15 минут в секундах
});
});
// POST /api/auth/refresh
router.post('/refresh', (req: Request, res: Response) => {
const { refreshToken } = req.body;
if (!refreshToken) {
res.status(401).json({ error: 'Refresh token is required' });
return;
}
if (!refreshTokens.includes(refreshToken)) {
res.status(403).json({ error: 'Invalid refresh token' });
return;
}
try {
const decoded = verifyRefreshToken(refreshToken);
const payload = { userId: decoded.userId, email: decoded.email, role: decoded.role };
const newAccessToken = generateAccessToken(payload);
res.json({
accessToken: newAccessToken,
tokenType: 'Bearer',
expiresIn: 900,
});
} catch (error) {
res.status(403).json({ error: 'Expired or invalid refresh token' });