Back to blog

Build a URL Shortener: Authentication & User Management

typescriptnodejsauthenticationjwtbackend
Build a URL Shortener: Authentication & User Management

Tới thời điểm này, URL shortener của chúng ta là "ai cũng xài được". Bất kỳ ai cũng shorten URL được, và chẳng có khái niệm ownership gì cả. Với toy project thì ok, nhưng URL shortener thật sự cần trả lời được: ai tạo link này?

Trong bài này, chúng ta sẽ thêm user authentication và URL ownership. User sẽ register, login, nhận JWT tokens, và manage URL của riêng mình. Chúng ta cũng sẽ thêm API keys cho programmatic access — vì không phải ai cũng muốn click button trên dashboard.

Thời gian: 2–3 giờ
Prerequisites: Phase 5: Caching với Redis

Những gì chúng ta sẽ build trong bài này:
✅ User registration với email và bcrypt password hashing
✅ Login endpoint phát hành JWT access + refresh tokens
✅ Auth middleware để protect routes
✅ User-scoped URL management (list, update, delete URL của tôi)
✅ API key generation cho programmatic access
✅ Token refresh flow cho seamless session management


Authentication Architecture

Trước khi viết code, hãy hiểu token strategy:

Tại Sao Access + Refresh Tokens?

Một token duy nhất sống lâu là security risk — nếu bị đánh cắp, attacker có access trong nhiều ngày hoặc tuần. Token ngắn hạn thì an toàn hơn nhưng phiền (user phải login lại mỗi 15 phút).

Strategy hai token giải quyết vấn đề này:

TokenLifetimePurposeLưu ở đâu
Access token15 phútAuthenticate API requestsClient memory
Refresh token7 ngàyLấy access token mớiDatabase + client

Nếu access token bị đánh cắp, damage window chỉ 15 phút. Nếu refresh token bị đánh cắp, ta có thể revoke nó từ database.


Prisma Schema — Users và Refresh Tokens

Đầu tiên, mở rộng Prisma schema với các model liên quan đến user:

// prisma/schema.prisma
 
model User {
  id          String    @id @default(uuid())
  email       String    @unique
  password    String    // bcrypt hash
  name        String?
  apiKey      String?   @unique @map("api_key")
  isActive    Boolean   @default(true) @map("is_active")
  createdAt   DateTime  @default(now()) @map("created_at")
  updatedAt   DateTime  @updatedAt @map("updated_at")
 
  urls          Url[]
  refreshTokens RefreshToken[]
 
  @@map("users")
}
 
model RefreshToken {
  id        String   @id @default(uuid())
  token     String   @unique
  userId    String   @map("user_id")
  expiresAt DateTime @map("expires_at")
  createdAt DateTime @default(now()) @map("created_at")
 
  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
 
  @@index([token])
  @@index([userId])
  @@map("refresh_tokens")
}

Và update model Url hiện tại để support optional ownership:

model Url {
  id          String    @id @default(uuid())
  shortCode   String    @unique @map("short_code")
  originalUrl String    @map("original_url")
  clickCount  Int       @default(0) @map("click_count")
  userId      String?   @map("user_id")   // null = anonymous
  customAlias Boolean   @default(false) @map("custom_alias")
  expiresAt   DateTime? @map("expires_at")
  createdAt   DateTime  @default(now()) @map("created_at")
  updatedAt   DateTime  @updatedAt @map("updated_at")
 
  user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
 
  @@index([shortCode])
  @@index([userId])
  @@map("urls")
}

Các quyết định design quan trọng:

  • userId là optional — anonymous users vẫn shorten URL được (backward compatible)
  • RefreshToken table — lưu refresh tokens để ta có thể revoke
  • apiKey trên User — mỗi user một API key, generate on demand
  • Cascade delete trên refresh tokens — khi user bị xóa, tất cả tokens cũng bị xóa
  • SetNull trên URLs — khi user bị xóa, URLs của họ vẫn còn nhưng mất ownership

Chạy migration:

npx prisma migrate dev --name add-users-and-auth

Install Dependencies

npm install bcrypt jsonwebtoken
npm install -D @types/bcrypt @types/jsonwebtoken
  • bcrypt — industry-standard password hashing với salt rounds
  • jsonwebtoken — JWT creation và verification

Environment Variables

Thêm vào .env:

# Auth
JWT_ACCESS_SECRET=your-access-token-secret-change-in-production
JWT_REFRESH_SECRET=your-refresh-token-secret-change-in-production
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d

Update src/config/env.ts:

// src/config/env.ts
 
import dotenv from 'dotenv';
 
dotenv.config();
 
export const env = {
  port: parseInt(process.env.PORT || '3000', 10),
  baseUrl: process.env.BASE_URL || 'http://localhost:3000',
  nodeEnv: process.env.NODE_ENV || 'development',
  databaseUrl: process.env.DATABASE_URL || '',
  redisUrl: process.env.REDIS_URL || 'redis://localhost:6379',
 
  // Auth
  jwtAccessSecret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret',
  jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret',
  jwtAccessExpiry: process.env.JWT_ACCESS_EXPIRY || '15m',
  jwtRefreshExpiry: process.env.JWT_REFRESH_EXPIRY || '7d',
} as const;

Auth Types

Define các types dùng xuyên suốt auth system:

// src/types/auth.ts
 
export interface RegisterRequest {
  email: string;
  password: string;
  name?: string;
}
 
export interface LoginRequest {
  email: string;
  password: string;
}
 
export interface AuthTokens {
  accessToken: string;
  refreshToken: string;
}
 
export interface TokenPayload {
  userId: string;
  email: string;
}
 
export interface AuthenticatedRequest {
  userId: string;
  email: string;
}
 
export interface UserResponse {
  id: string;
  email: string;
  name: string | null;
  createdAt: Date;
}
 
export interface ApiKeyResponse {
  apiKey: string;
}

Auth Service

Auth service xử lý registration, login, token management và API keys. Đây là core của authentication system:

// src/services/authService.ts
 
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { PrismaClient } from '@prisma/client';
import { env } from '../config/env';
import { AppError } from './urlService';
import {
  RegisterRequest,
  LoginRequest,
  AuthTokens,
  TokenPayload,
  UserResponse,
} from '../types/auth';
 
const prisma = new PrismaClient();
 
const SALT_ROUNDS = 12;
 
export class AuthService {
  // ─── Registration ──────────────────────────────────────
 
  async register(data: RegisterRequest): Promise<UserResponse> {
    const { email, password, name } = data;
 
    // Kiểm tra email đã tồn tại chưa
    const existing = await prisma.user.findUnique({
      where: { email: email.toLowerCase() },
    });
 
    if (existing) {
      throw new AppError('Email is already registered', 409);
    }
 
    // Validate password strength
    this.validatePassword(password);
 
    // Hash password
    const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
 
    // Tạo user
    const user = await prisma.user.create({
      data: {
        email: email.toLowerCase(),
        password: hashedPassword,
        name: name || null,
      },
    });
 
    return this.toUserResponse(user);
  }
 
  // ─── Login ──────────────────────────────────────────────
 
  async login(data: LoginRequest): Promise<AuthTokens> {
    const { email, password } = data;
 
    // Tìm user
    const user = await prisma.user.findUnique({
      where: { email: email.toLowerCase() },
    });
 
    if (!user) {
      // Dùng cùng message cho email sai và password sai
      // để tránh email enumeration
      throw new AppError('Invalid email or password', 401);
    }
 
    if (!user.isActive) {
      throw new AppError('Account is deactivated', 403);
    }
 
    // Verify password
    const isValid = await bcrypt.compare(password, user.password);
 
    if (!isValid) {
      throw new AppError('Invalid email or password', 401);
    }
 
    // Generate tokens
    return this.generateTokens(user.id, user.email);
  }
 
  // ─── Token Management ──────────────────────────────────
 
  async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string }> {
    // Tìm refresh token trong database
    const storedToken = await prisma.refreshToken.findUnique({
      where: { token: refreshToken },
      include: { user: true },
    });
 
    if (!storedToken) {
      throw new AppError('Invalid refresh token', 401);
    }
 
    // Kiểm tra token hết hạn chưa
    if (storedToken.expiresAt < new Date()) {
      // Dọn dẹp token hết hạn
      await prisma.refreshToken.delete({
        where: { id: storedToken.id },
      });
      throw new AppError('Refresh token has expired', 401);
    }
 
    // Verify JWT signature
    try {
      jwt.verify(refreshToken, env.jwtRefreshSecret);
    } catch {
      await prisma.refreshToken.delete({
        where: { id: storedToken.id },
      });
      throw new AppError('Invalid refresh token', 401);
    }
 
    // Generate access token mới
    const accessToken = this.generateAccessToken(
      storedToken.user.id,
      storedToken.user.email
    );
 
    return { accessToken };
  }
 
  async logout(refreshToken: string): Promise<void> {
    await prisma.refreshToken.deleteMany({
      where: { token: refreshToken },
    });
  }
 
  async logoutAllDevices(userId: string): Promise<void> {
    await prisma.refreshToken.deleteMany({
      where: { userId },
    });
  }
 
  // ─── API Keys ──────────────────────────────────────────
 
  async generateApiKey(userId: string): Promise<{ apiKey: string }> {
    const apiKey = `usk_${crypto.randomBytes(32).toString('hex')}`;
 
    await prisma.user.update({
      where: { id: userId },
      data: { apiKey },
    });
 
    return { apiKey };
  }
 
  async revokeApiKey(userId: string): Promise<void> {
    await prisma.user.update({
      where: { id: userId },
      data: { apiKey: null },
    });
  }
 
  async validateApiKey(apiKey: string): Promise<TokenPayload | null> {
    const user = await prisma.user.findUnique({
      where: { apiKey },
    });
 
    if (!user || !user.isActive) {
      return null;
    }
 
    return { userId: user.id, email: user.email };
  }
 
  // ─── User Profile ──────────────────────────────────────
 
  async getProfile(userId: string): Promise<UserResponse> {
    const user = await prisma.user.findUnique({
      where: { id: userId },
    });
 
    if (!user) {
      throw new AppError('User not found', 404);
    }
 
    return this.toUserResponse(user);
  }
 
  // ─── Private Helpers ───────────────────────────────────
 
  private validatePassword(password: string): void {
    if (password.length < 8) {
      throw new AppError('Password must be at least 8 characters', 400);
    }
 
    if (password.length > 128) {
      throw new AppError('Password must be at most 128 characters', 400);
    }
 
    if (!/[A-Z]/.test(password)) {
      throw new AppError('Password must contain at least one uppercase letter', 400);
    }
 
    if (!/[a-z]/.test(password)) {
      throw new AppError('Password must contain at least one lowercase letter', 400);
    }
 
    if (!/[0-9]/.test(password)) {
      throw new AppError('Password must contain at least one number', 400);
    }
  }
 
  private async generateTokens(userId: string, email: string): Promise<AuthTokens> {
    const accessToken = this.generateAccessToken(userId, email);
    const refreshToken = this.generateRefreshToken(userId, email);
 
    // Tính expiry của refresh token
    const expiresAt = new Date();
    expiresAt.setDate(expiresAt.getDate() + 7); // 7 ngày
 
    // Lưu refresh token vào database
    await prisma.refreshToken.create({
      data: {
        token: refreshToken,
        userId,
        expiresAt,
      },
    });
 
    return { accessToken, refreshToken };
  }
 
  private generateAccessToken(userId: string, email: string): string {
    const payload: TokenPayload = { userId, email };
    return jwt.sign(payload, env.jwtAccessSecret, {
      expiresIn: env.jwtAccessExpiry,
    });
  }
 
  private generateRefreshToken(userId: string, email: string): string {
    const payload: TokenPayload = { userId, email };
    return jwt.sign(payload, env.jwtRefreshSecret, {
      expiresIn: env.jwtRefreshExpiry,
    });
  }
 
  private toUserResponse(user: {
    id: string;
    email: string;
    name: string | null;
    createdAt: Date;
  }): UserResponse {
    return {
      id: user.id,
      email: user.email,
      name: user.name,
      createdAt: user.createdAt,
    };
  }
}

Các Quyết Định Security Quan Trọng

12 salt rounds cho bcrypt — mỗi round tăng gấp đôi thời gian hashing. 12 rounds mất ~250ms, đủ chậm để ngăn brute-force attacks nhưng đủ nhanh để user không thấy phiền.

Salt RoundsThời gian mỗi HashMục đích
8~40msQuá nhanh, bảo mật yếu
10~100msMinimum cho production
12~250msCân bằng tốt
14~1sChế độ paranoid

Cùng error message cho sai email và sai password — "Invalid email or password" ngăn attacker phát hiện email nào tồn tại trong system (email enumeration).

API key format — prefix usk_ giúp nhận diện key ngay lập tức. Nếu key bị lộ trong log, bạn biết ngay đó là gì. 32-byte hex cho 256 bits entropy.


Auth Middleware

Middleware extract và verify JWT từ Authorization header, sau đó gắn user info vào request:

// src/middleware/auth.ts
 
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { env } from '../config/env';
import { TokenPayload } from '../types/auth';
import { AuthService } from '../services/authService';
 
const authService = new AuthService();
 
// Mở rộng Express Request để include user info
declare global {
  namespace Express {
    interface Request {
      user?: TokenPayload;
    }
  }
}
 
export function verifyToken(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  // Kiểm tra Bearer token
  const authHeader = req.headers.authorization;
 
  // Kiểm tra API key
  const apiKey = req.headers['x-api-key'] as string;
 
  if (!authHeader && !apiKey) {
    res.status(401).json({ error: 'Authentication required' });
    return;
  }
 
  // Thử API key trước
  if (apiKey) {
    authService
      .validateApiKey(apiKey)
      .then((payload) => {
        if (!payload) {
          res.status(401).json({ error: 'Invalid API key' });
          return;
        }
        req.user = payload;
        next();
      })
      .catch(next);
    return;
  }
 
  // Thử Bearer token
  if (authHeader) {
    const parts = authHeader.split(' ');
 
    if (parts.length !== 2 || parts[0] !== 'Bearer') {
      res.status(401).json({
        error: 'Invalid authorization format. Use: Bearer <token>',
      });
      return;
    }
 
    const token = parts[1];
 
    try {
      const payload = jwt.verify(token, env.jwtAccessSecret) as TokenPayload;
      req.user = payload;
      next();
    } catch (err) {
      if (err instanceof jwt.TokenExpiredError) {
        res.status(401).json({
          error: 'Access token has expired',
          code: 'TOKEN_EXPIRED',
        });
        return;
      }
 
      res.status(401).json({ error: 'Invalid access token' });
      return;
    }
  }
}
 
// Optional auth — gắn user nếu có token, nhưng không reject
export function optionalAuth(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const authHeader = req.headers.authorization;
  const apiKey = req.headers['x-api-key'] as string;
 
  if (!authHeader && !apiKey) {
    next();
    return;
  }
 
  // Delegate cho verifyToken, nhưng bắt 401s và tiếp tục
  verifyToken(req, res, (err) => {
    if (err) {
      // Authentication thất bại, nhưng ok — tiếp tục không có user
      next();
    } else {
      next();
    }
  });
}

Hai Chế Độ Authentication

verifyToken — nghiêm ngặt. Trả 401 nếu không có valid token. Dùng cho protected routes.

optionalAuth — linh hoạt. Gắn user info nếu có, tiếp tục bình thường nếu không. Dùng cho URL shortening endpoint để authenticated users có URL ownership trong khi anonymous users vẫn hoạt động.

Tại Sao Support Cả Bearer Token Và API Key?

Bearer tokens dành cho interactive sessions (browser, mobile app). API keys dành cho automation (CI/CD, scripts, integrations). Support cả hai trong cùng middleware giữ routes sạch sẽ — route không cần quan tâm bạn authenticate bằng cách nào.


Auth Validation

Thêm request validation cho registration và login:

// src/middleware/validateAuth.ts
 
import { Request, Response, NextFunction } from 'express';
 
export function validateRegister(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const { email, password } = req.body;
 
  if (!email || typeof email !== 'string') {
    res.status(400).json({ error: 'Email is required' });
    return;
  }
 
  // Kiểm tra format email cơ bản
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(email.trim())) {
    res.status(400).json({ error: 'Invalid email format' });
    return;
  }
 
  if (!password || typeof password !== 'string') {
    res.status(400).json({ error: 'Password is required' });
    return;
  }
 
  // Normalize email
  req.body.email = email.trim().toLowerCase();
  next();
}
 
export function validateLogin(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const { email, password } = req.body;
 
  if (!email || typeof email !== 'string') {
    res.status(400).json({ error: 'Email is required' });
    return;
  }
 
  if (!password || typeof password !== 'string') {
    res.status(400).json({ error: 'Password is required' });
    return;
  }
 
  req.body.email = email.trim().toLowerCase();
  next();
}
 
export function validateRefreshToken(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const { refreshToken } = req.body;
 
  if (!refreshToken || typeof refreshToken !== 'string') {
    res.status(400).json({ error: 'Refresh token is required' });
    return;
  }
 
  next();
}

Auth Routes

// src/routes/authRoutes.ts
 
import { Router, Request, Response, NextFunction } from 'express';
import { AuthService } from '../services/authService';
import { verifyToken } from '../middleware/auth';
import {
  validateRegister,
  validateLogin,
  validateRefreshToken,
} from '../middleware/validateAuth';
 
const router = Router();
const authService = new AuthService();
 
// POST /api/auth/register
router.post(
  '/register',
  validateRegister,
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const user = await authService.register(req.body);
      res.status(201).json({
        message: 'Registration successful',
        user,
      });
    } catch (error) {
      next(error);
    }
  }
);
 
// POST /api/auth/login
router.post(
  '/login',
  validateLogin,
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const tokens = await authService.login(req.body);
      res.json({
        message: 'Login successful',
        ...tokens,
      });
    } catch (error) {
      next(error);
    }
  }
);
 
// POST /api/auth/refresh
router.post(
  '/refresh',
  validateRefreshToken,
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const result = await authService.refreshAccessToken(req.body.refreshToken);
      res.json(result);
    } catch (error) {
      next(error);
    }
  }
);
 
// POST /api/auth/logout
router.post(
  '/logout',
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const { refreshToken } = req.body;
      if (refreshToken) {
        await authService.logout(refreshToken);
      }
      res.json({ message: 'Logged out successfully' });
    } catch (error) {
      next(error);
    }
  }
);
 
// POST /api/auth/logout-all — revoke tất cả refresh tokens
router.post(
  '/logout-all',
  verifyToken,
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      await authService.logoutAllDevices(req.user!.userId);
      res.json({ message: 'Logged out from all devices' });
    } catch (error) {
      next(error);
    }
  }
);
 
// GET /api/auth/profile
router.get(
  '/profile',
  verifyToken,
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const profile = await authService.getProfile(req.user!.userId);
      res.json(profile);
    } catch (error) {
      next(error);
    }
  }
);
 
// POST /api/auth/api-key — generate API key mới
router.post(
  '/api-key',
  verifyToken,
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const result = await authService.generateApiKey(req.user!.userId);
      res.status(201).json({
        message: 'API key generated. Store it securely — it cannot be retrieved later.',
        ...result,
      });
    } catch (error) {
      next(error);
    }
  }
);
 
// DELETE /api/auth/api-key — revoke API key
router.delete(
  '/api-key',
  verifyToken,
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      await authService.revokeApiKey(req.user!.userId);
      res.json({ message: 'API key revoked' });
    } catch (error) {
      next(error);
    }
  }
);
 
export default router;

User-Scoped URL Management

Giờ đến phần quan trọng — cho phép users manage URL của riêng họ. Chúng ta cần service xử lý URL CRUD operations scoped theo authenticated user:

// src/services/userUrlService.ts
 
import { PrismaClient } from '@prisma/client';
import { env } from '../config/env';
import { AppError } from './urlService';
 
const prisma = new PrismaClient();
 
export interface UserUrl {
  id: string;
  shortCode: string;
  shortUrl: string;
  originalUrl: string;
  clickCount: number;
  customAlias: boolean;
  expiresAt: Date | null;
  createdAt: Date;
}
 
export interface UpdateUrlRequest {
  originalUrl?: string;
  expiresAt?: string | null;
}
 
export interface PaginatedResult<T> {
  data: T[];
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
}
 
export class UserUrlService {
  // ─── List URL của User ──────────────────────────────────
 
  async listUrls(
    userId: string,
    page: number = 1,
    pageSize: number = 20,
    search?: string
  ): Promise<PaginatedResult<UserUrl>> {
    const skip = (page - 1) * pageSize;
 
    const where: any = { userId };
 
    if (search) {
      where.OR = [
        { shortCode: { contains: search, mode: 'insensitive' } },
        { originalUrl: { contains: search, mode: 'insensitive' } },
      ];
    }
 
    const [urls, total] = await Promise.all([
      prisma.url.findMany({
        where,
        orderBy: { createdAt: 'desc' },
        skip,
        take: pageSize,
      }),
      prisma.url.count({ where }),
    ]);
 
    return {
      data: urls.map(this.toUserUrl),
      total,
      page,
      pageSize,
      totalPages: Math.ceil(total / pageSize),
    };
  }
 
  // ─── Lấy Một URL ────────────────────────────────────────
 
  async getUrl(shortCode: string, userId: string): Promise<UserUrl> {
    const url = await prisma.url.findFirst({
      where: { shortCode, userId },
    });
 
    if (!url) {
      throw new AppError('URL not found or does not belong to you', 404);
    }
 
    return this.toUserUrl(url);
  }
 
  // ─── Update URL ────────────────────────────────────────
 
  async updateUrl(
    shortCode: string,
    userId: string,
    data: UpdateUrlRequest
  ): Promise<UserUrl> {
    // Verify ownership
    const existing = await prisma.url.findFirst({
      where: { shortCode, userId },
    });
 
    if (!existing) {
      throw new AppError('URL not found or does not belong to you', 404);
    }
 
    // Validate URL mới nếu có
    if (data.originalUrl) {
      try {
        const parsed = new URL(data.originalUrl);
        if (!['http:', 'https:'].includes(parsed.protocol)) {
          throw new AppError('Only HTTP and HTTPS URLs are supported', 400);
        }
      } catch (err) {
        if (err instanceof AppError) throw err;
        throw new AppError('Invalid URL format', 400);
      }
    }
 
    // Build update data
    const updateData: any = {};
    if (data.originalUrl) {
      updateData.originalUrl = data.originalUrl;
    }
    if (data.expiresAt !== undefined) {
      updateData.expiresAt = data.expiresAt ? new Date(data.expiresAt) : null;
    }
 
    const updated = await prisma.url.update({
      where: { id: existing.id },
      data: updateData,
    });
 
    return this.toUserUrl(updated);
  }
 
  // ─── Delete URL ────────────────────────────────────────
 
  async deleteUrl(shortCode: string, userId: string): Promise<void> {
    const existing = await prisma.url.findFirst({
      where: { shortCode, userId },
    });
 
    if (!existing) {
      throw new AppError('URL not found or does not belong to you', 404);
    }
 
    await prisma.url.delete({
      where: { id: existing.id },
    });
  }
 
  // ─── URL Stats ─────────────────────────────────────────
 
  async getUrlStats(userId: string): Promise<{
    totalUrls: number;
    totalClicks: number;
    urlsThisMonth: number;
  }> {
    const now = new Date();
    const firstOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
 
    const [totalUrls, clickAgg, urlsThisMonth] = await Promise.all([
      prisma.url.count({ where: { userId } }),
      prisma.url.aggregate({
        where: { userId },
        _sum: { clickCount: true },
      }),
      prisma.url.count({
        where: {
          userId,
          createdAt: { gte: firstOfMonth },
        },
      }),
    ]);
 
    return {
      totalUrls,
      totalClicks: clickAgg._sum.clickCount || 0,
      urlsThisMonth,
    };
  }
 
  // ─── Private Helpers ───────────────────────────────────
 
  private toUserUrl(url: any): UserUrl {
    return {
      id: url.id,
      shortCode: url.shortCode,
      shortUrl: `${env.baseUrl}/${url.shortCode}`,
      originalUrl: url.originalUrl,
      clickCount: url.clickCount,
      customAlias: url.customAlias,
      expiresAt: url.expiresAt,
      createdAt: url.createdAt,
    };
  }
}

Kiểm Tra Ownership

Mọi operation đều check { shortCode, userId } — users chỉ có thể xem và sửa URL của riêng mình. Đây là nguyên tắc security cơ bản gọi là row-level access control. Không có nó, bất kỳ authenticated user nào cũng có thể xóa URL của người khác.


User URL Routes

// src/routes/userUrlRoutes.ts
 
import { Router, Request, Response, NextFunction } from 'express';
import { UserUrlService } from '../services/userUrlService';
import { verifyToken } from '../middleware/auth';
 
const router = Router();
const userUrlService = new UserUrlService();
 
// Tất cả routes yêu cầu authentication
router.use(verifyToken);
 
// GET /api/my/urls — list URL của tôi với pagination
router.get(
  '/urls',
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const page = parseInt(req.query.page as string) || 1;
      const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
      const search = req.query.search as string | undefined;
 
      const result = await userUrlService.listUrls(
        req.user!.userId,
        page,
        pageSize,
        search
      );
      res.json(result);
    } catch (error) {
      next(error);
    }
  }
);
 
// GET /api/my/urls/:code — lấy một URL của tôi
router.get(
  '/urls/:code',
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const url = await userUrlService.getUrl(
        req.params.code,
        req.user!.userId
      );
      res.json(url);
    } catch (error) {
      next(error);
    }
  }
);
 
// PATCH /api/my/urls/:code — update một URL của tôi
router.patch(
  '/urls/:code',
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const url = await userUrlService.updateUrl(
        req.params.code,
        req.user!.userId,
        req.body
      );
      res.json(url);
    } catch (error) {
      next(error);
    }
  }
);
 
// DELETE /api/my/urls/:code — xóa một URL của tôi
router.delete(
  '/urls/:code',
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      await userUrlService.deleteUrl(req.params.code, req.user!.userId);
      res.status(204).send();
    } catch (error) {
      next(error);
    }
  }
);
 
// GET /api/my/stats — thống kê URL của tôi
router.get(
  '/stats',
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const stats = await userUrlService.getUrlStats(req.user!.userId);
      res.json(stats);
    } catch (error) {
      next(error);
    }
  }
);
 
export default router;

Update URL Shortening Cho Ownership

Giờ cần update endpoint URL shortening hiện tại để gắn user ID khi authenticated user tạo link. Update UrlService:

// Trong src/services/urlService.ts — update method shortenUrl
 
async shortenUrl(
  originalUrl: string,
  customAlias?: string,
  userId?: string    // MỚI: optional user ID
): Promise<CreateUrlResponse> {
  let shortCode: string;
 
  if (customAlias) {
    if (!isValidCustomAlias(customAlias)) {
      throw new AppError(
        'Invalid custom alias. Use 3-30 alphanumeric characters, hyphens, or underscores.',
        400
      );
    }
 
    const existingCode = await prisma.url.findUnique({
      where: { shortCode: customAlias },
    });
 
    if (existingCode) {
      throw new AppError('Custom alias is already taken', 409);
    }
 
    shortCode = customAlias;
  } else {
    shortCode = await this.generateUniqueCode();
  }
 
  const record = await prisma.url.create({
    data: {
      shortCode,
      originalUrl,
      userId: userId || null,     // Gắn user nếu authenticated
      customAlias: !!customAlias,
    },
  });
 
  return this.toCreateResponse(record.shortCode, record.originalUrl, record.createdAt);
}

Và update route để pass user ID:

// Trong src/routes/urlRoutes.ts — update POST /shorten handler
 
import { optionalAuth } from '../middleware/auth';
 
router.post(
  '/shorten',
  optionalAuth,          // Dùng optional auth
  validateShortenRequest,
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const { url, customAlias } = req.body;
      const result = await urlService.shortenUrl(
        url,
        customAlias,
        req.user?.userId    // Pass user ID nếu authenticated
      );
      res.status(201).json(result);
    } catch (error) {
      next(error);
    }
  }
);

Đây là backward compatible — anonymous users vẫn shorten URL được. Nhưng authenticated users sẽ có URL linked với account.


Wire Routes Vào Express App

Update app.ts để mount auth và user URL routes mới:

// src/app.ts
 
import express from 'express';
import urlRoutes from './routes/urlRoutes';
import redirectRoutes from './routes/redirectRoutes';
import authRoutes from './routes/authRoutes';
import userUrlRoutes from './routes/userUrlRoutes';
import { errorHandler } from './middleware/errorHandler';
 
const app = express();
 
app.use(express.json({ limit: '10kb' }));
 
// Health check
app.get('/health', (_req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
 
// API routes
app.use('/api/auth', authRoutes);
app.use('/api/my', userUrlRoutes);
app.use('/api', urlRoutes);
 
// Redirect catch-all (phải ở cuối)
app.use(redirectRoutes);
 
// Error handler
app.use(errorHandler);
 
export default app;

Thứ tự mount routes:

  1. /api/auth/* — authentication endpoints
  2. /api/my/* — user-scoped URL management (yêu cầu auth)
  3. /api/* — public URL shortening endpoints
  4. /:code — redirect catch-all

Security Best Practices

Lưu Trữ Password

Tuyệt đối không lưu plaintext password. Luôn hash với bcrypt và salt rounds đủ lớn:

// SAI — lưu plaintext
await prisma.user.create({
  data: { email, password: plaintext },
});
 
// ĐÚNG — hash trước
const hashed = await bcrypt.hash(plaintext, 12);
await prisma.user.create({
  data: { email, password: hashed },
});

Token Storage (Client Side)

StorageAccess TokenRefresh Token
localStorageDễ bị XSSTuyệt đối không lưu ở đây
sessionStorageMất khi đóng tabTuyệt đối không lưu ở đây
Memory (variable)Tốt nhất cho access tokensKhông persistent
HttpOnly cookieAlternative tốtTốt nhất cho refresh tokens

Với API-based approach, client lưu access token trong memory và refresh token trong HttpOnly cookie hoặc secure storage.

Rate Limiting Auth Endpoints

Auth endpoints là target chính cho brute-force attacks. Thêm rate limiting chặt hơn:

// src/middleware/authRateLimit.ts
 
import { Request, Response, NextFunction } from 'express';
 
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
 
const MAX_ATTEMPTS = 5;
const WINDOW_MS = 15 * 60 * 1000; // 15 phút
 
export function loginRateLimit(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const key = req.ip || 'unknown';
  const now = Date.now();
 
  const record = loginAttempts.get(key);
 
  if (record) {
    if (now > record.resetAt) {
      // Window hết hạn, reset
      loginAttempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
      next();
      return;
    }
 
    if (record.count >= MAX_ATTEMPTS) {
      const retryAfter = Math.ceil((record.resetAt - now) / 1000);
      res.status(429).json({
        error: 'Too many login attempts',
        retryAfter,
      });
      return;
    }
 
    record.count += 1;
  } else {
    loginAttempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
  }
 
  next();
}

Áp dụng cho login route:

import { loginRateLimit } from '../middleware/authRateLimit';
 
router.post('/login', loginRateLimit, validateLogin, async (req, res, next) => {
  // ...
});

5 lần login mỗi 15 phút mỗi IP. Sau đó, user phải đợi. Trong production, dùng Redis thay vì in-memory Map (xem Phase 5).

Token Cleanup

Refresh tokens hết hạn sẽ chất đống. Thêm cleanup function:

// Trong authService.ts
 
async cleanupExpiredTokens(): Promise<number> {
  const result = await prisma.refreshToken.deleteMany({
    where: {
      expiresAt: { lt: new Date() },
    },
  });
 
  return result.count;
}

Chạy function này theo schedule (cron job) hoặc như một phần của refresh flow.


Cập Nhật Folder Structure

url-shortener/
├── src/
│   ├── config/
│   │   └── env.ts                # + JWT config
│   ├── middleware/
│   │   ├── auth.ts               # MỚI: JWT + API key verification
│   │   ├── authRateLimit.ts      # MỚI: login rate limiting
│   │   ├── errorHandler.ts
│   │   ├── validateAuth.ts       # MỚI: auth request validation
│   │   └── validateRequest.ts
│   ├── routes/
│   │   ├── authRoutes.ts         # MỚI: register, login, refresh
│   │   ├── redirectRoutes.ts
│   │   ├── urlRoutes.ts          # CẬP NHẬT: optional auth
│   │   └── userUrlRoutes.ts      # MỚI: user-scoped URL CRUD
│   ├── services/
│   │   ├── authService.ts        # MỚI: auth business logic
│   │   ├── urlService.ts         # CẬP NHẬT: userId parameter
│   │   └── userUrlService.ts     # MỚI: user URL management
│   ├── types/
│   │   ├── auth.ts               # MỚI: auth types
│   │   └── url.ts
│   ├── app.ts                    # CẬP NHẬT: route mounts mới
│   └── index.ts
├── prisma/
│   └── schema.prisma             # CẬP NHẬT: User + RefreshToken models
├── .env
└── package.json

Test Auth Flow

Khởi động dev server và test với curl:

Test 1: Register User

curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "password": "SecurePass123",
    "name": "Alice"
  }'
{
  "message": "Registration successful",
  "user": {
    "id": "a1b2c3d4-...",
    "email": "[email protected]",
    "name": "Alice",
    "createdAt": "2026-03-21T10:00:00.000Z"
  }
}

Test 2: Login

curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "password": "SecurePass123"
  }'
{
  "message": "Login successful",
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}

Lưu access token cho các request tiếp theo:

export TOKEN="eyJhbGciOiJIUzI1NiIs..."

Test 3: Shorten URL (Authenticated)

curl -X POST http://localhost:3000/api/shorten \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"url": "https://github.com/expressjs/express"}'
{
  "shortCode": "aBc1X9z",
  "shortUrl": "http://localhost:3000/aBc1X9z",
  "originalUrl": "https://github.com/expressjs/express",
  "createdAt": "2026-03-21T10:01:00.000Z"
}

URL này giờ đã linked với account của Alice.

Test 4: List URL Của Tôi

curl http://localhost:3000/api/my/urls \
  -H "Authorization: Bearer $TOKEN"
{
  "data": [
    {
      "id": "...",
      "shortCode": "aBc1X9z",
      "shortUrl": "http://localhost:3000/aBc1X9z",
      "originalUrl": "https://github.com/expressjs/express",
      "clickCount": 0,
      "customAlias": false,
      "expiresAt": null,
      "createdAt": "2026-03-21T10:01:00.000Z"
    }
  ],
  "total": 1,
  "page": 1,
  "pageSize": 20,
  "totalPages": 1
}

Test 5: Update URL

curl -X PATCH http://localhost:3000/api/my/urls/aBc1X9z \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"originalUrl": "https://github.com/expressjs/express/releases"}'
{
  "id": "...",
  "shortCode": "aBc1X9z",
  "shortUrl": "http://localhost:3000/aBc1X9z",
  "originalUrl": "https://github.com/expressjs/express/releases",
  "clickCount": 0,
  "customAlias": false,
  "expiresAt": null,
  "createdAt": "2026-03-21T10:01:00.000Z"
}

Test 6: Delete URL

curl -X DELETE http://localhost:3000/api/my/urls/aBc1X9z \
  -H "Authorization: Bearer $TOKEN" \
  -v
< HTTP/1.1 204 No Content

Test 7: Generate API Key

curl -X POST http://localhost:3000/api/auth/api-key \
  -H "Authorization: Bearer $TOKEN"
{
  "message": "API key generated. Store it securely — it cannot be retrieved later.",
  "apiKey": "usk_a1b2c3d4e5f6..."
}

Test 8: Dùng API Key

export API_KEY="usk_a1b2c3d4e5f6..."
 
curl -X POST http://localhost:3000/api/shorten \
  -H "Content-Type: application/json" \
  -H "x-api-key: $API_KEY" \
  -d '{"url": "https://nodejs.org"}'
{
  "shortCode": "xY7mN2q",
  "shortUrl": "http://localhost:3000/xY7mN2q",
  "originalUrl": "https://nodejs.org",
  "createdAt": "2026-03-21T10:05:00.000Z"
}

Test 9: Refresh Token

curl -X POST http://localhost:3000/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken": "eyJhbGciOiJIUzI1NiIs..."}'
{
  "accessToken": "eyJhbGciOiJIUzI1NiIs..."
}

Test 10: Error Cases

Email trùng:

curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "AnotherPass123"}'
{ "error": "Email is already registered" }

Password yếu:

curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "weak"}'
{ "error": "Password must be at least 8 characters" }

Token hết hạn:

{
  "error": "Access token has expired",
  "code": "TOKEN_EXPIRED"
}

code: "TOKEN_EXPIRED" cho phép client biết nên thử refresh flow thay vì yêu cầu user login lại.


API Summary

Đây là toàn bộ API sau bài này:

MethodEndpointAuthMô tả
POST/api/auth/registerKhôngTạo account
POST/api/auth/loginKhôngLấy tokens
POST/api/auth/refreshKhôngRefresh access token
POST/api/auth/logoutKhôngRevoke refresh token
POST/api/auth/logout-allCóRevoke tất cả refresh tokens
GET/api/auth/profileCóLấy user profile
POST/api/auth/api-keyCóGenerate API key
DELETE/api/auth/api-keyCóRevoke API key
POST/api/shortenOptionalShorten URL
GET/api/urls/:codeKhôngLấy URL stats
GET/api/my/urlsCóList URL của tôi
GET/api/my/urls/:codeCóLấy một URL của tôi
PATCH/api/my/urls/:codeCóUpdate URL của tôi
DELETE/api/my/urls/:codeCóXóa URL của tôi
GET/api/my/statsCóThống kê URL
GET/:codeKhôngRedirect
GET/healthKhôngHealth check

Các Lỗi Thường Gặp Cần Tránh

1. Lưu JWT Secrets Trong Code

// SAI — secret trong source code
const secret = 'my-super-secret-key';
 
// ĐÚNG — load từ environment
const secret = process.env.JWT_ACCESS_SECRET;

2. Không Kiểm Tra Token Ownership Khi Update/Delete

// SAI — ai cũng xóa được bất kỳ URL nào
const url = await prisma.url.findUnique({ where: { shortCode } });
await prisma.url.delete({ where: { id: url.id } });
 
// ĐÚNG — verify ownership
const url = await prisma.url.findFirst({
  where: { shortCode, userId },
});
if (!url) throw new AppError('Not found or not yours', 404);
await prisma.url.delete({ where: { id: url.id } });

3. Lộ Password Hashes Trong API Responses

// SAI — gửi hết, bao gồm password hash
res.json(user);
 
// ĐÚNG — map sang safe response
res.json({
  id: user.id,
  email: user.email,
  name: user.name,
  createdAt: user.createdAt,
});

4. Dùng Cùng Secret Cho Access Và Refresh Tokens

# SAI — cùng secret cho cả hai
JWT_SECRET=one-secret-for-everything
 
# ĐÚNG — secrets riêng biệt
JWT_ACCESS_SECRET=access-secret-here
JWT_REFRESH_SECRET=different-refresh-secret

Nếu cả hai tokens dùng chung secret, access token bị lộ có thể được dùng như refresh token (hoặc ngược lại), phá hủy toàn bộ mục đích của two-token strategy.


Tiếp Theo Là Gì?

Chúng ta giờ đã có user authentication, URL ownership và API keys. Trong Phase 7, ta sẽ build React frontend:

  • Vite + React + TypeScript project setup
  • Trang login và registration
  • Form shorten URL với hiển thị kết quả
  • Dashboard với paginated URL list
  • Biểu đồ click stats
  • API key management UI

API đã sẵn sàng — giờ cần một giao diện cho nó.

Series: Build a URL Shortener
Previous: Phase 5: Caching với Redis
Next: Phase 7: Frontend với React

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.