Refresh Token + Token Rotation

~25 daq · NestJS · Autentifikatsiya

Muammodan boshlaylik

Oldingi darsda access token muddatini 15 daqiqa qildik. To'g'ri qaror — xavfsiz. Lekin muammo bor: 15 daqiqada token o'chadi, foydalanuvchi qayta login qilishi kerak. Har 15 daqiqada! Bu qabul qilib bo'lmaydi.

Muddatni uzaytirsa bo'ladi — 7 kun. Lekin token o'g'irlansa 7 kun davomida hamma narsaga kirish mumkin.

Refresh Token bu ikkilamchini hal qiladi: qisqa access token + uzun refresh token.


Refresh Token qanday ishlaydi?

Ikkita token:

Access Token  — 15 daqiqa, har so'rovda ishlatiladi
Refresh Token — 7 kun, faqat yangi access token olish uchun

Hayotiy o'xshatma: kutubxona kartasi. Bitta kunlik o'tish taloni (access token) — har kuni yangilanadi. Bitta yillik a'zolik kartasi (refresh token) — faqat yangi o'tish taloni olish uchun ko'rsatiladi.

Oqim:

1. Login → access token (15 daq) + refresh token (7 kun)
2. So'rov → access token bilan
3. Access token o'chdi → refresh token bilan yangi access token oling
4. Agar refresh token ham o'chdi → qayta login

Token Rotation nima?

Har refresh token ishlatilganda — yangi refresh token ham beriladi, eskilik bekor qilinadi. Bu — Token Rotation.

Nima uchun kerak? Agar refresh token o'g'irlangan bo'lsa va xaker undan foydalansa — siz ham o'sha tokenni ishlatmoqchi bo'lasiz. Ikki so'rov keladi bir xil token bilan. Server: "Bu token allaqachon ishlatilgan — ikkala sessiya ham bekor qilinadi, ikkalasi ham qayta login qilsin."


Amalga oshirish

User sxemasiga refresh token qo'shish

// src/users/schemas/user.schema.ts
@Schema({ timestamps: true })
export class User {
  @Prop({ required: true })
  name: string;

  @Prop({ required: true, unique: true })
  email: string;

  @Prop({ required: true, select: false })
  password: string;

  // Refresh token hash saqlanadi (plain emas)
  @Prop({ select: false, default: null })
  refreshToken: string | null;
}

Nima uchun hash? Refresh token ham parol kabi maxfiy. Baza buzilsa plain tokenlar o'g'irlanadi. Hash qilinsa — yaroqsiz.

Auth Service — ikkala token

// src/auth/auth.service.ts
import { Injectable, ForbiddenException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../users/users.service';
import * as bcrypt from 'bcrypt';

@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService,
    private config: ConfigService,
  ) {}

  async validateUser(email: string, password: string) {
    const user = await this.usersService.findByEmail(email);
    if (!user) return null;
    const isMatch = await bcrypt.compare(password, user.password);
    if (!isMatch) return null;
    const { password: _, ...result } = user.toObject();
    return result;
  }

  // Login — ikkala token yaratish
  async login(user: any) {
    const tokens = await this.generateTokens(user._id, user.email, user.role);
    await this.saveRefreshToken(user._id, tokens.refreshToken);
    return { ...tokens, user: { id: user._id, name: user.name, email: user.email } };
  }

  // Refresh — yangi access token + yangi refresh token
  async refresh(userId: string, refreshToken: string) {
    const user = await this.usersService.findByIdWithToken(userId);
    if (!user || !user.refreshToken) {
      throw new ForbiddenException('Kirish taqiqlangan');
    }

    // Kelgan token bilan bazadagi hashni solishtiramiz
    const tokenMatch = await bcrypt.compare(refreshToken, user.refreshToken);
    if (!tokenMatch) {
      // Token o'g'irlangan bo'lishi mumkin — sessiyani butunlay tozalaymiz
      await this.usersService.clearRefreshToken(userId);
      throw new ForbiddenException('Token yaroqsiz — qayta login qiling');
    }

    // Token Rotation — yangi refresh token beriladi, eski bekor
    const tokens = await this.generateTokens(userId, user.email, user.role);
    await this.saveRefreshToken(userId, tokens.refreshToken);
    return tokens;
  }

  // Logout — refresh tokenni o'chirish
  async logout(userId: string) {
    await this.usersService.clearRefreshToken(userId);
    return { message: 'Chiqildi' };
  }

  // Yordamchi: ikkala tokenni yaratish
  private async generateTokens(userId: string, email: string, role: string) {
    const payload = { sub: userId, email, role };

    const [accessToken, refreshToken] = await Promise.all([
      this.jwtService.signAsync(payload, {
        secret: this.config.get('JWT_SECRET'),
        expiresIn: this.config.get('JWT_EXPIRES_IN'), // '15m'
      }),
      this.jwtService.signAsync(payload, {
        secret: this.config.get('JWT_REFRESH_SECRET'), // boshqa secret!
        expiresIn: this.config.get('JWT_REFRESH_EXPIRES_IN'), // '7d'
      }),
    ]);

    return { accessToken, refreshToken };
  }

  // Yordamchi: refresh tokenni hash qilib saqlash
  private async saveRefreshToken(userId: string, token: string) {
    const hash = await bcrypt.hash(token, 10);
    await this.usersService.updateRefreshToken(userId, hash);
  }
}

Nima uchun alohida JWT_REFRESH_SECRET? Access va refresh token bir xil secret bilan imzolansa — biri o'g'irlansa ikkinchisi ham yaroqsiz bo'ladi, lekin bu aniqlanmaydi. Alohida secret — agar access secret ochilib qolsa, refresh tokenlar hali xavfsiz.

UsersService ga yangi metodlar

// src/users/users.service.ts
async findByIdWithToken(id: string) {
  return this.userModel.findById(id).select('+refreshToken').exec();
}

async updateRefreshToken(id: string, hash: string | null) {
  return this.userModel.findByIdAndUpdate(id, { refreshToken: hash }).exec();
}

async clearRefreshToken(id: string) {
  return this.updateRefreshToken(id, null);
}

Refresh Token Guard va Strategy

// src/auth/strategies/jwt-refresh.strategy.ts
import { Injectable, ForbiddenException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';

@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
  constructor(private config: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: config.get('JWT_REFRESH_SECRET'), // ← refresh secret
      passReqToCallback: true, // ← req ni validate ga uzatish
    });
  }

  async validate(req: Request, payload: any) {
    // Authorization: Bearer <token> — tokenni olamiz
    const refreshToken = req.get('Authorization')?.replace('Bearer', '').trim();
    if (!refreshToken) throw new ForbiddenException();
    return { ...payload, refreshToken }; // ← req.user
  }
}
// src/auth/guards/jwt-refresh.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtRefreshGuard extends AuthGuard('jwt-refresh') {}

Controller — to'liq auth endpoints

// src/auth/auth.controller.ts
import { Controller, Post, Get, UseGuards, Req } from '@nestjs/common';
import { LocalAuthGuard } from './guards/local-auth.guard';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { JwtRefreshGuard } from './guards/jwt-refresh.guard';
import { Public } from './decorators/public.decorator';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  // POST /auth/login
  @Post('login')
  @Public()
  @UseGuards(LocalAuthGuard)
  login(@Req() req) {
    return this.authService.login(req.user);
  }

  // POST /auth/refresh — yangi access token olish
  @Post('refresh')
  @Public()
  @UseGuards(JwtRefreshGuard) // ← refresh token tekshiradi
  refresh(@Req() req) {
    const { sub, refreshToken } = req.user;
    return this.authService.refresh(sub, refreshToken);
  }

  // POST /auth/logout
  @Post('logout')
  @UseGuards(JwtAuthGuard)
  logout(@Req() req) {
    return this.authService.logout(req.user.sub);
  }

  // GET /auth/me
  @Get('me')
  @UseGuards(JwtAuthGuard)
  me(@Req() req) {
    return req.user;
  }
}

.env

JWT_SECRET=access-token-secret-kamida-32-belgi
JWT_EXPIRES_IN=15m

JWT_REFRESH_SECRET=refresh-token-secret-boshqa-kamida-32-belgi
JWT_REFRESH_EXPIRES_IN=7d

Frontend da token boshqarish

Login qilganda:
  → accessToken localStorage/memory da saqlang
  → refreshToken httpOnly cookie da saqlang (xavfsizroq)

Har so'rovda:
  Authorization: Bearer <accessToken>

401 kelsa (token o'chdi):
  → POST /auth/refresh
  → Yangi accessToken oling
  → Asl so'rovni qayta yuboring

Refresh ham ishlamasa:
  → Login sahifasiga yo'naltiring

Tez-tez uchraydigan xatolar

Refresh tokenni localStorage da saqlash

❌ localStorage — XSS hujumida o'qiladi
✅ httpOnly cookie — JavaScript o'qiy olmaydi, xavfsizroq

Bitta secret ishlatish

# ❌ Bir xil secret
JWT_SECRET=mysecret
JWT_REFRESH_SECRET=mysecret

# ✅ Har biri alohida
JWT_SECRET=access_aK9mP2vL3qR8nT5wY1uA6bZ4
JWT_REFRESH_SECRET=refresh_cE7fH0iJ2kN5pQ8rS1tU3

Token rotation amalga oshirmaslik

// ❌ Har doim bir xil refresh token — o'g'irlansa bilinmaydi
async refresh(userId, refreshToken) {
  // tekshirish ...
  return { accessToken: this.jwtService.sign(...) };
  // ← yangi refresh token berilmadi
}

// ✅ Har refresh da yangi refresh token
const tokens = await this.generateTokens(...);
await this.saveRefreshToken(userId, tokens.refreshToken); // ← yangilandi
return tokens;

Xulosa

Refresh Token — access token qisqa bo'lsa ham foydalanuvchi doimiy kirib turishi uchun mexanizm. Token Rotation — har ishlatganda yangi refresh token, eski bekor — xavfsizlik qatlami.

Eslab qoling:

  • Access token → 15m, Refresh token → 7d
  • Ikki alohida secret — JWT_SECRET va JWT_REFRESH_SECRET
  • Refresh token bazada hash sifatida saqlanadi
  • Token Rotation — eski token ishlatilsa yangi beriladi, eski o'chadi
  • Baza buzilsa → clearRefreshToken bilan barcha sessiyalarni bekor qiling

Keyingi dars

28-dars: Google OAuth / Social Login