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:
| Token | Lifetime | Purpose | Lưu ở đâu |
|---|---|---|---|
| Access token | 15 phút | Authenticate API requests | Client memory |
| Refresh token | 7 ngày | Lấy access token mới | Database + 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:
userIdlà optional — anonymous users vẫn shorten URL được (backward compatible)RefreshTokentable — lưu refresh tokens để ta có thể revokeapiKeytrê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-authInstall 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=7dUpdate 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 Rounds | Thời gian mỗi Hash | Mục đích |
|---|---|---|
| 8 | ~40ms | Quá nhanh, bảo mật yếu |
| 10 | ~100ms | Minimum cho production |
| 12 | ~250ms | Cân bằng tốt |
| 14 | ~1s | Chế độ 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:
/api/auth/*— authentication endpoints/api/my/*— user-scoped URL management (yêu cầu auth)/api/*— public URL shortening endpoints/: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)
| Storage | Access Token | Refresh Token |
|---|---|---|
| localStorage | Dễ bị XSS | Tuyệt đối không lưu ở đây |
| sessionStorage | Mất khi đóng tab | Tuyệt đối không lưu ở đây |
| Memory (variable) | Tốt nhất cho access tokens | Không persistent |
| HttpOnly cookie | Alternative tốt | Tố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.jsonTest 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 ContentTest 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:
| Method | Endpoint | Auth | Mô tả |
|---|---|---|---|
POST | /api/auth/register | Không | Tạo account |
POST | /api/auth/login | Không | Lấy tokens |
POST | /api/auth/refresh | Không | Refresh access token |
POST | /api/auth/logout | Không | Revoke refresh token |
POST | /api/auth/logout-all | Có | Revoke tất cả refresh tokens |
GET | /api/auth/profile | Có | Lấy user profile |
POST | /api/auth/api-key | Có | Generate API key |
DELETE | /api/auth/api-key | Có | Revoke API key |
POST | /api/shorten | Optional | Shorten URL |
GET | /api/urls/:code | Không | Lấy URL stats |
GET | /api/my/urls | Có | List URL của tôi |
GET | /api/my/urls/:code | Có | Lấy một URL của tôi |
PATCH | /api/my/urls/:code | Có | Update URL của tôi |
DELETE | /api/my/urls/:code | Có | Xóa URL của tôi |
GET | /api/my/stats | Có | Thống kê URL |
GET | /:code | Không | Redirect |
GET | /health | Không | Health 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-secretNế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.