เจาะลึก: Session-Based Authentication
Session-Based Authentication คืออะไร?
Session-based authentication เป็นรูปแบบที่เก่าแก่ที่สุดและตรงไปตรงมาที่สุดสำหรับการรักษาตัวตนของผู้ใช้ในลำดับ HTTP requests HTTP โดยธรรมชาติเป็นแบบ stateless — แต่ละ request เป็นอิสระ Sessions แก้ปัญหานี้โดยให้ server สร้างและจัดเก็บบันทึกของผู้ใช้ที่ได้รับการตรวจสอบ จากนั้นส่งให้ client ตัวระบุที่ไม่ซ้ำกัน (session ID) เป็น cookie ในแต่ละ request ที่ตามมา เบราว์เซอร์จะส่ง cookie นี้โดยอัตโนมัติ ซึ่งอนุญาตให้ server ค้นหา session ที่จัดเก็บและจดจำผู้ใช้
แนวคิดนี้ย้อนไปถึงแอปพลิเคชันเว็บยุคแรกสุดในช่วงปลายทศวรรษ 1990 และยังคงเป็นกลไกการตรวจสอบสิทธิ์เริ่มต้นในเฟรมเวิร์กเช่น Rails, Django, Laravel และ Express.js แม้ว่า token-based approaches จะได้รับความนิยมเพิ่มขึ้น session auth ยังคงเป็นตัวเลือกที่ถูกต้องสำหรับสถาปัตยกรรมจำนวนมาก — โดยเฉพาะอย่างยิ่งสำหรับแอปพลิเคชันที่ render server side ที่ simplicity และ instant revocability มีความสำคัญมากกว่า statelessness
หลักการหลัก
- Stateful: Server เป็น source of truth แล้ว ข้อมูล session ทั้งหมดอยู่ฝั่ง server; client จึงถือเพียง opaque reference เท่านั้น
- Cookie-based transport: Session ID เดินทางผ่าน HTTP-only cookie — ไม่มองเห็นได้สำหรับ JavaScript ส่งอัตโนมัติโดย browser
- Instant revocation: การลบหรือ invalidate session บน server ออก user ทันที ไม่ต้องรอให้ token expire
- Shared store requirement: ในการ deploy หลาย server ทุก instance ต้องเข้าถึง session store เดียวกัน (เช่น Redis)
Authentication Flow
sequenceDiagram
participant B as Browser
participant S as Server
participant R as Session Store (Redis)
B->>S: POST /login {email, password}
S->>S: Validate credentials
S->>R: Create session {userId, email, role}
R-->>S: Session ID: "aF9kL2mN7x"
S-->>B: Set-Cookie: sessionId=aF9kL2mN7x (HttpOnly, Secure)
Note over B: Cookie stored automatically
B->>S: GET /dashboard (Cookie: sessionId=aF9kL2mN7x)
S->>R: Lookup session "aF9kL2mN7x"
R-->>S: {userId: "user123", email, role}
S-->>B: 200 OK — Dashboard HTML
B->>S: POST /logout (Cookie: sessionId=aF9kL2mN7x)
S->>R: Delete session "aF9kL2mN7x"
R-->>S: Deleted
S-->>B: Set-Cookie: sessionId=; Max-Age=0
ตอนนี้ลองดูวิธีการ implement อย่างถูกต้องในการใช้งานจริง
How Sessions Work: The Request/Response Lifecycle
Session-based authentication ทำงานตามหลักการที่เรียบง่ายแต่ทรงพลัง: server จะเก็บข้อมูลสถานะของผู้ใช้ที่ได้รับการตรวจสอบ และ client นำเสนอ token (session ID) ในแต่ละ request เพื่อระบุตัวตนของตนเอง
The Complete Flow
User Login Request
↓
Server validates credentials
↓
Server creates session object and stores it
↓
Server generates session ID (cryptographically random)
↓
Server sends Set-Cookie header: sessionId=<random-string>
↓
Browser stores cookie automatically
↓
Browser includes Cookie: sessionId=<random-string> on all subsequent requests
↓
Server retrieves session data from storage using session ID
↓
Server authenticates user and processes request
↓
Response sent back to client
ลักษณะที่สำคัญคือ session ID เป็นเพียงการอ้างอิงเท่านั้น — ไม่มีความหมายหากไม่มี session store ของ server นี่แตกต่างจากพื้นฐาน JWT ซึ่ง token เองมีข้อมูลผู้ใช้ (แม้ว่าจะลงนาม)
Memory Diagram (Text Representation)
เมื่อผู้ใช้เข้าสู่ระบบ นี่คือสิ่งที่เกิดขึ้นในหน่วยความจำ:
Browser Memory:
┌─────────────────────────────┐
│ Cookies │
├─────────────────────────────┤
│ sessionId: "aF9kL2mN7xQ" │
└─────────────────────────────┘
↕ (sent with each request)
Server Memory / Session Store:
┌─────────────────────────────────────┐
│ Session Store (Redis / Database) │
├─────────────────────────────────────┤
│ "aF9kL2mN7xQ": { │
│ userId: "user123", │
│ username: "alice", │
│ email: "alice@example.com", │
│ loginTime: 1712973600000, │
│ lastActivity: 1712973600000, │
│ ipAddress: "192.168.1.1", │
│ userAgent: "Mozilla/5.0...", │
│ data: { role: "admin", ... } │
│ } │
└─────────────────────────────────────┘
ในแต่ละ request server จะ:
- ดึง session ID จาก cookie
- ค้นหา session ใน store
- ตรวจสอบ session (วันหมดอายุ ตรวจสอบความปลอดภัย)
- แนบข้อมูล session เข้ากับ request object
- ประมวลผล request ด้วยบริบทที่สมบูรณ์เกี่ยวกับผู้ใช้ที่ได้รับการตรวจสอบ
Implementation with Express.js + express-session
ให้เราสร้างการใช้งานระดับการใช้งานจริงโดยใช้ Express.js, express-session และ Redis เป็น session store ของเรา
Installation
npm install express express-session connect-redis redis dotenv bcryptjs
npm install -D typescript @types/express @types/express-session @types/node @types/bcryptjs
Session Configuration
// src/config/sessionConfig.ts
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
import type { SessionOptions } from 'express-session';
// Initialize Redis client
export const redisClient = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
socket: {
reconnectStrategy: (retries) => {
if (retries > 10) {
console.error('Redis reconnection failed after 10 attempts');
return new Error('Redis max retries exceeded');
}
return retries * 100; // Exponential backoff
},
},
});
redisClient.on('error', (err) => {
console.error('Redis error:', err);
});
redisClient.on('connect', () => {
console.log('Connected to Redis');
});
// Connect Redis client
export const initializeRedis = async () => {
await redisClient.connect();
};
// Session configuration
export const sessionConfig: SessionOptions = {
// Store configuration
store: new RedisStore({ client: redisClient }),
// Session ID configuration
genid: (req) => {
// Use cryptographically secure random ID generation
return require('crypto').randomBytes(32).toString('hex');
},
// Secret for signing session ID cookie
secret: process.env.SESSION_SECRET || 'your-super-secret-key-change-in-production',
// Resave: save session even if unmodified
// Set to false for better performance with Redis
resave: false,
// Save uninitialized sessions
// Set to false for better security (don't create sessions until login)
saveUninitialized: false,
// Name of the session ID cookie
name: 'sessionId',
// Session expiration (24 hours)
cookie: {
// CRITICAL: Always use httpOnly in production
httpOnly: true,
// CRITICAL: Always use secure in production (HTTPS only)
secure: process.env.NODE_ENV === 'production',
// Prevent CSRF attacks by restricting when cookie is sent
sameSite: 'strict' as const,
// Session lifetime (24 hours in milliseconds)
maxAge: 24 * 60 * 60 * 1000,
// Domain restriction (optional but recommended)
// domain: '.example.com', // Uncomment for production
// Path restriction (optional)
path: '/',
},
// Proxy configuration (important for production behind reverse proxy)
proxy: process.env.NODE_ENV === 'production',
};import org.springframework.context.annotation.*;
import org.springframework.session.data.redis.config.annotation.web.http.*;
import org.springframework.data.redis.connection.*;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.boot.web.servlet.ServletContextInitializer;
import jakarta.servlet.SessionCookieConfig;
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 86400) // 24 hours
public class SessionConfig {
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration cfg = new RedisStandaloneConfiguration();
cfg.setHostName(System.getenv().getOrDefault("REDIS_HOST", "localhost"));
cfg.setPort(Integer.parseInt(System.getenv().getOrDefault("REDIS_PORT", "6379")));
return new LettuceConnectionFactory(cfg);
}
/** Configure the session cookie (httpOnly, Secure, SameSite=Strict). */
@Bean
public ServletContextInitializer sessionCookieConfig() {
return servletContext -> {
SessionCookieConfig cookieConfig = servletContext.getSessionCookieConfig();
cookieConfig.setName("sessionId");
cookieConfig.setHttpOnly(true);
cookieConfig.setSecure(true); // HTTPS only in production
cookieConfig.setPath("/");
cookieConfig.setMaxAge(86400); // 24 hours
// SameSite is set via response header or server config
};
}
}# requirements: fastapi, redis, itsdangerous, starlette
import os
import secrets
from starlette.middleware.sessions import SessionMiddleware
from starlette.applications import Starlette
import redis.asyncio as aioredis
# Redis client (async)
redis_client = aioredis.from_url(
os.getenv("REDIS_URL", "redis://localhost:6379"),
encoding="utf-8",
decode_responses=True,
)
SESSION_SECRET = os.getenv("SESSION_SECRET", secrets.token_hex(32))
SESSION_TTL = 24 * 60 * 60 # 24 hours in seconds
# For FastAPI / Starlette, add the built-in SessionMiddleware
# (backed by a signed cookie — for Redis-backed sessions use a custom store)
from fastapi import FastAPI
app = FastAPI()
app.add_middleware(
SessionMiddleware,
secret_key = SESSION_SECRET,
session_cookie = "sessionId",
max_age = SESSION_TTL,
https_only = os.getenv("ENVIRONMENT") == "production",
same_site = "strict",
)
# Redis-backed custom session store helper
async def save_session(session_id: str, data: dict) -> None:
import json
await redis_client.setex(f"session:{session_id}", SESSION_TTL, json.dumps(data))
async def get_session(session_id: str) -> dict | None:
import json
raw = await redis_client.get(f"session:{session_id}")
return json.loads(raw) if raw else None
async def delete_session(session_id: str) -> None:
await redis_client.delete(f"session:{session_id}")// Program.cs — ASP.NET Core 8 session configuration
using Microsoft.AspNetCore.DataProtection;
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);
// Redis connection
var redis = ConnectionMultiplexer.Connect(
builder.Configuration.GetConnectionString("Redis") ?? "localhost:6379");
builder.Services.AddSingleton<IConnectionMultiplexer>(redis);
// Distributed session backed by Redis
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "session:";
});
builder.Services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromHours(24);
options.Cookie.Name = "sessionId";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.IsEssential = true;
options.Cookie.Path = "/";
});
// Data protection (signs/encrypts session cookie value)
builder.Services.AddDataProtection()
.PersistKeysToStackExchangeRedis(redis, "DataProtection-Keys");
var app = builder.Build();
app.UseSession();
app.MapControllers();
app.Run();User Model and Database Layer
// src/models/User.ts
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
export interface User {
id: string;
username: string;
email: string;
passwordHash: string;
createdAt: Date;
lastLoginAt: Date | null;
loginAttempts: number;
lockoutUntil: Date | null;
}
export interface SessionData {
userId: string;
username: string;
email: string;
role: string;
loginTime: number;
lastActivity: number;
ipAddress: string;
userAgent: string;
}
// Simulated database
const users: Map<string, User> = new Map();
export class UserService {
static async createUser(
username: string,
email: string,
password: string
): Promise<User> {
const id = crypto.randomBytes(16).toString('hex');
const passwordHash = await bcrypt.hash(password, 12);
const user: User = {
id,
username,
email,
passwordHash,
createdAt: new Date(),
lastLoginAt: null,
loginAttempts: 0,
lockoutUntil: null,
};
users.set(id, user);
return user;
}
static async verifyCredentials(
email: string,
password: string
): Promise<User | null> {
// Find user by email
let user: User | null = null;
for (const u of users.values()) {
if (u.email === email) {
user = u;
break;
}
}
if (!user) {
return null;
}
// Check if account is locked
if (user.lockoutUntil && user.lockoutUntil > new Date()) {
throw new Error('Account is temporarily locked due to too many login attempts');
}
// Verify password
const isValid = await bcrypt.compare(password, user.passwordHash);
if (isValid) {
// Reset login attempts on successful login
user.loginAttempts = 0;
user.lockoutUntil = null;
user.lastLoginAt = new Date();
return user;
} else {
// Increment login attempts and implement lockout
user.loginAttempts += 1;
if (user.loginAttempts >= 5) {
user.lockoutUntil = new Date(Date.now() + 15 * 60 * 1000); // 15-minute lockout
}
return null;
}
}
static async getUserById(id: string): Promise<User | null> {
return users.get(id) || null;
}
}import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public record User(
String id,
String username,
String email,
String passwordHash,
Instant createdAt,
Instant lastLoginAt,
int loginAttempts,
Instant lockoutUntil
) {}
public record SessionData(
String userId,
String username,
String email,
String role,
long loginTime,
long lastActivity,
String ipAddress,
String userAgent
) {}
@Service
public class UserService {
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12);
private final Map<String, User> users = new ConcurrentHashMap<>();
public User createUser(String username, String email, String password) {
String id = UUID.randomUUID().toString();
String passwordHash = encoder.encode(password);
User user = new User(
id, username, email, passwordHash,
Instant.now(), null, 0, null
);
users.put(id, user);
return user;
}
public User verifyCredentials(String email, String password) {
User user = users.values().stream()
.filter(u -> u.email().equals(email))
.findFirst()
.orElse(null);
if (user == null) return null;
if (user.lockoutUntil() != null && user.lockoutUntil().isAfter(Instant.now())) {
throw new RuntimeException(
"Account is temporarily locked due to too many login attempts");
}
if (encoder.matches(password, user.passwordHash())) {
// Reset attempts — return updated user
User updated = new User(user.id(), user.username(), user.email(),
user.passwordHash(), user.createdAt(), Instant.now(), 0, null);
users.put(user.id(), updated);
return updated;
} else {
int attempts = user.loginAttempts() + 1;
Instant lockout = attempts >= 5
? Instant.now().plusSeconds(15 * 60)
: user.lockoutUntil();
users.put(user.id(), new User(user.id(), user.username(), user.email(),
user.passwordHash(), user.createdAt(), user.lastLoginAt(), attempts, lockout));
return null;
}
}
public Optional<User> getUserById(String id) {
return Optional.ofNullable(users.get(id));
}
}import bcrypt
import secrets
from datetime import datetime, timedelta, timezone
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class User:
id: str
username: str
email: str
password_hash: str
created_at: datetime
last_login_at: Optional[datetime] = None
login_attempts: int = 0
lockout_until: Optional[datetime] = None
@dataclass
class SessionData:
user_id: str
username: str
email: str
role: str
login_time: float
last_activity: float
ip_address: str
user_agent: str
# In-memory store (replace with a real DB in production)
_users: dict[str, User] = {}
class UserService:
@staticmethod
async def create_user(username: str, email: str, password: str) -> User:
user_id = secrets.token_hex(16)
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)).decode()
user = User(
id=user_id,
username=username,
email=email,
password_hash=password_hash,
created_at=datetime.now(timezone.utc),
)
_users[user_id] = user
return user
@staticmethod
async def verify_credentials(email: str, password: str) -> Optional[User]:
user = next((u for u in _users.values() if u.email == email), None)
if not user:
return None
now = datetime.now(timezone.utc)
if user.lockout_until and user.lockout_until > now:
raise ValueError("Account is temporarily locked due to too many login attempts")
if bcrypt.checkpw(password.encode(), user.password_hash.encode()):
user.login_attempts = 0
user.lockout_until = None
user.last_login_at = now
return user
else:
user.login_attempts += 1
if user.login_attempts >= 5:
user.lockout_until = now + timedelta(minutes=15)
return None
@staticmethod
async def get_user_by_id(user_id: str) -> Optional[User]:
return _users.get(user_id)using Microsoft.AspNetCore.Identity;
public class User
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Username { get; set; } = "";
public string Email { get; set; } = "";
public string PasswordHash { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastLoginAt { get; set; }
public int LoginAttempts { get; set; }
public DateTime? LockoutUntil { get; set; }
}
public class SessionData
{
public string UserId { get; set; } = "";
public string Username { get; set; } = "";
public string Email { get; set; } = "";
public string Role { get; set; } = "";
public long LoginTime { get; set; }
public long LastActivity { get; set; }
public string IpAddress { get; set; } = "";
public string UserAgent { get; set; } = "";
}
public class UserService
{
private readonly Dictionary<string, User> _users = new();
private readonly IPasswordHasher<User> _hasher = new PasswordHasher<User>();
public Task<User> CreateUserAsync(string username, string email, string password)
{
var user = new User { Username = username, Email = email };
user.PasswordHash = _hasher.HashPassword(user, password);
_users[user.Id] = user;
return Task.FromResult(user);
}
public Task<User?> VerifyCredentialsAsync(string email, string password)
{
var user = _users.Values.FirstOrDefault(u => u.Email == email);
if (user is null) return Task.FromResult<User?>(null);
if (user.LockoutUntil.HasValue && user.LockoutUntil > DateTime.UtcNow)
throw new Exception("Account is temporarily locked");
var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (result == PasswordVerificationResult.Success)
{
user.LoginAttempts = 0;
user.LockoutUntil = null;
user.LastLoginAt = DateTime.UtcNow;
return Task.FromResult<User?>(user);
}
user.LoginAttempts++;
if (user.LoginAttempts >= 5)
user.LockoutUntil = DateTime.UtcNow.AddMinutes(15);
return Task.FromResult<User?>(null);
}
public Task<User?> GetUserByIdAsync(string id) =>
Task.FromResult(_users.GetValueOrDefault(id));
}Express Application Setup
// src/app.ts
import express, { Express, Request, Response, NextFunction } from 'express';
import session from 'express-session';
import { sessionConfig } from './config/sessionConfig';
import { UserService, SessionData } from './models/User';
import csrf from 'csurf';
import cookieParser from 'cookie-parser';
import crypto from 'crypto';
// Extend Express Request type to include session data
declare global {
namespace Express {
interface Request {
sessionID: string;
session: {
userId?: string;
userData?: SessionData;
csrfToken?: string;
};
}
}
}
export const createApp = (): Express => {
const app = express();
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser(process.env.SESSION_SECRET || 'your-secret'));
// Session middleware
app.use(session(sessionConfig));
// CSRF protection middleware
const csrfProtection = csrf({ cookie: false });
// Middleware to attach session data to request
app.use(async (req: Request, res: Response, next: NextFunction) => {
if (req.session.userId) {
const user = await UserService.getUserById(req.session.userId);
if (user) {
req.session.userData = {
userId: user.id,
username: user.username,
email: user.email,
role: 'user', // You'd get this from your database
loginTime: req.session.loginTime || Date.now(),
lastActivity: Date.now(),
ipAddress: req.ip || '',
userAgent: req.get('User-Agent') || '',
};
}
}
next();
});
// Authentication middleware
const requireAuth = (req: Request, res: Response, next: NextFunction) => {
if (!req.session.userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
next();
};
// ===== Public Routes =====
// Register endpoint
app.post('/api/auth/register', csrfProtection, async (req: Request, res: Response) => {
try {
const { username, email, password, confirmPassword } = req.body;
// Validation
if (!username || !email || !password || !confirmPassword) {
return res.status(400).json({ error: 'Missing required fields' });
}
if (password !== confirmPassword) {
return res.status(400).json({ error: 'Passwords do not match' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
const user = await UserService.createUser(username, email, password);
// Create session after registration
req.session.userId = user.id;
req.session.loginTime = Date.now();
res.status(201).json({
message: 'User created successfully',
user: { id: user.id, username: user.username, email: user.email },
});
} catch (error) {
console.error('Registration error:', error);
res.status(500).json({ error: 'Registration failed' });
}
});
// Login endpoint
app.post('/api/auth/login', csrfProtection, async (req: Request, res: Response) => {
try {
const { email, password, rememberMe } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
const user = await UserService.verifyCredentials(email, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Create session
req.session.userId = user.id;
req.session.loginTime = Date.now();
// Optional: Extend session if "remember me" is selected
if (rememberMe) {
req.session.cookie.maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days
}
// Regenerate session ID to prevent session fixation attacks
req.session.regenerate((err) => {
if (err) {
return res.status(500).json({ error: 'Session creation failed' });
}
res.json({
message: 'Login successful',
user: {
id: user.id,
username: user.username,
email: user.email,
},
});
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Login failed' });
}
});
// Get CSRF token for forms
app.get('/api/csrf-token', csrfProtection, (req: Request, res: Response) => {
res.json({ csrfToken: csrfProtection(req, res) });
});
// ===== Protected Routes =====
// Get current user
app.get('/api/auth/me', requireAuth, (req: Request, res: Response) => {
res.json({
user: req.session.userData,
sessionId: req.sessionID,
});
});
// Logout endpoint
app.post('/api/auth/logout', (req: Request, res: Response) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
// Clear session cookie
res.clearCookie('sessionId', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
});
res.json({ message: 'Logout successful' });
});
});
// Protected resource example
app.get('/api/protected-resource', requireAuth, (req: Request, res: Response) => {
res.json({
message: 'This is a protected resource',
accessedBy: req.session.userData?.username,
timestamp: new Date().toISOString(),
});
});
// Health check
app.get('/health', (req: Request, res: Response) => {
res.json({ status: 'healthy' });
});
return app;
};import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.*;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserService userService;
public AuthController(UserService userService) {
this.userService = userService;
}
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegisterRequest body, HttpSession session) {
if (!body.password().equals(body.confirmPassword()))
return ResponseEntity.badRequest().body(Map.of("error", "Passwords do not match"));
if (body.password().length() < 8)
return ResponseEntity.badRequest().body(Map.of("error", "Password too short"));
User user = userService.createUser(body.username(), body.email(), body.password());
session.setAttribute("userId", user.id());
session.setAttribute("loginTime", System.currentTimeMillis());
return ResponseEntity.status(201).body(Map.of(
"message", "User created successfully",
"user", Map.of("id", user.id(), "username", user.username(), "email", user.email())
));
}
@PostMapping("/login")
public ResponseEntity<?> login(
@RequestBody LoginRequest body,
HttpServletRequest request,
HttpServletResponse response
) {
User user = userService.verifyCredentials(body.email(), body.password());
if (user == null)
return ResponseEntity.status(401).body(Map.of("error", "Invalid credentials"));
// Invalidate old session and create a new one (prevent fixation)
request.getSession(false);
request.getSession().invalidate();
HttpSession newSession = request.getSession(true);
newSession.setAttribute("userId", user.id());
newSession.setAttribute("loginTime", System.currentTimeMillis());
if (Boolean.TRUE.equals(body.rememberMe()))
newSession.setMaxInactiveInterval(7 * 24 * 60 * 60); // 7 days
return ResponseEntity.ok(Map.of(
"message", "Login successful",
"user", Map.of("id", user.id(), "username", user.username(), "email", user.email())
));
}
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletRequest request, HttpServletResponse response) {
HttpSession session = request.getSession(false);
if (session != null) session.invalidate();
// Clear cookie
Cookie cookie = new Cookie("sessionId", "");
cookie.setMaxAge(0);
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setSecure(true);
response.addCookie(cookie);
return ResponseEntity.ok(Map.of("message", "Logout successful"));
}
@GetMapping("/me")
public ResponseEntity<?> getMe(HttpSession session) {
String userId = (String) session.getAttribute("userId");
if (userId == null)
return ResponseEntity.status(401).body(Map.of("error", "Not authenticated"));
return userService.getUserById(userId)
.map(u -> ResponseEntity.ok(Map.of(
"user", Map.of("id", u.id(), "username", u.username(), "email", u.email()),
"sessionId", session.getId()
)))
.orElseGet(() -> ResponseEntity.status(404).body(Map.of("error", "User not found")));
}
}import secrets
import json
from datetime import datetime, timezone
from fastapi import FastAPI, Request, Response, HTTPException, Depends
from pydantic import BaseModel
app = FastAPI()
# --- Request models ---
class RegisterRequest(BaseModel):
username: str
email: str
password: str
confirm_password: str
class LoginRequest(BaseModel):
email: str
password: str
remember_me: bool = False
# --- Auth dependency ---
async def require_auth(request: Request) -> dict:
session_id = request.cookies.get("sessionId")
if not session_id:
raise HTTPException(status_code=401, detail="Not authenticated")
session = await get_session(session_id)
if not session or "user_id" not in session:
raise HTTPException(status_code=401, detail="Not authenticated")
return session
# --- Routes ---
@app.post("/api/auth/register", status_code=201)
async def register(body: RegisterRequest, response: Response):
if body.password != body.confirm_password:
raise HTTPException(status_code=400, detail="Passwords do not match")
if len(body.password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
user = await UserService.create_user(body.username, body.email, body.password)
session_id = secrets.token_hex(32)
await save_session(session_id, {"user_id": user.id, "login_time": datetime.now(timezone.utc).timestamp()})
response.set_cookie("sessionId", session_id, httponly=True, secure=True,
samesite="strict", max_age=86400)
return {"message": "User created successfully",
"user": {"id": user.id, "username": user.username, "email": user.email}}
@app.post("/api/auth/login")
async def login(body: LoginRequest, response: Response):
user = await UserService.verify_credentials(body.email, body.password)
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
session_id = secrets.token_hex(32)
ttl = 7 * 24 * 60 * 60 if body.remember_me else 24 * 60 * 60
await save_session(session_id, {"user_id": user.id, "login_time": datetime.now(timezone.utc).timestamp()})
response.set_cookie("sessionId", session_id, httponly=True, secure=True,
samesite="strict", max_age=ttl)
return {"message": "Login successful",
"user": {"id": user.id, "username": user.username, "email": user.email}}
@app.post("/api/auth/logout")
async def logout(request: Request, response: Response):
session_id = request.cookies.get("sessionId")
if session_id:
await delete_session(session_id)
response.delete_cookie("sessionId", path="/", secure=True, httponly=True, samesite="strict")
return {"message": "Logout successful"}
@app.get("/api/auth/me")
async def get_me(session: dict = Depends(require_auth)):
user = await UserService.get_user_by_id(session["user_id"])
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {"user": {"id": user.id, "username": user.username, "email": user.email}}using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http;
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
private readonly UserService _userService;
public AuthController(UserService userService)
{
_userService = userService;
}
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest body)
{
if (body.Password != body.ConfirmPassword)
return BadRequest(new { error = "Passwords do not match" });
if (body.Password.Length < 8)
return BadRequest(new { error = "Password must be at least 8 characters" });
var user = await _userService.CreateUserAsync(body.Username, body.Email, body.Password);
HttpContext.Session.SetString("userId", user.Id);
HttpContext.Session.SetString("loginTime", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString());
return StatusCode(201, new { message = "User created successfully",
user = new { user.Id, user.Username, user.Email } });
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest body)
{
var user = await _userService.VerifyCredentialsAsync(body.Email, body.Password);
if (user is null)
return Unauthorized(new { error = "Invalid credentials" });
// Regenerate session to prevent fixation
HttpContext.Session.Clear();
HttpContext.Session.SetString("userId", user.Id);
HttpContext.Session.SetString("loginTime", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString());
if (body.RememberMe)
{
// Extend cookie lifetime (configure in session options)
Response.Cookies.Append("sessionId", HttpContext.Session.Id, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
MaxAge = TimeSpan.FromDays(7),
});
}
return Ok(new { message = "Login successful",
user = new { user.Id, user.Username, user.Email } });
}
[HttpPost("logout")]
public IActionResult Logout()
{
HttpContext.Session.Clear();
Response.Cookies.Delete("sessionId", new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
Path = "/",
});
return Ok(new { message = "Logout successful" });
}
[HttpGet("me")]
public async Task<IActionResult> GetMe()
{
var userId = HttpContext.Session.GetString("userId");
if (userId is null) return Unauthorized(new { error = "Not authenticated" });
var user = await _userService.GetUserByIdAsync(userId);
if (user is null) return NotFound(new { error = "User not found" });
return Ok(new { user = new { user.Id, user.Username, user.Email },
sessionId = HttpContext.Session.Id });
}
}Server Entry Point
// src/server.ts
import { createApp } from './app';
import { initializeRedis } from './config/sessionConfig';
const PORT = process.env.PORT || 3000;
const startServer = async () => {
try {
// Initialize Redis connection
await initializeRedis();
console.log('Redis connection established');
// Create and start Express app
const app = createApp();
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
};
startServer();import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
// Spring Boot auto-configures Redis, session store, and embedded Tomcat.
// Redis connection is validated at startup via LettuceConnectionFactory.
SpringApplication.run(Application.class, args);
}
}import uvicorn
import asyncio
async def main():
# Verify Redis connectivity before accepting requests
await redis_client.ping()
print("Redis connection established")
config = uvicorn.Config(
app,
host="0.0.0.0",
port=int(os.getenv("PORT", 3000)),
log_level="info",
)
server = uvicorn.Server(config)
await server.serve()
if __name__ == "__main__":
asyncio.run(main())// Program.cs — startup is handled by the WebApplication builder
// (see Session Configuration section above)
// The app.Run() call starts Kestrel on the configured port.
// To verify Redis on startup, add a health check:
builder.Services.AddHealthChecks()
.AddRedis(
builder.Configuration.GetConnectionString("Redis")!,
name: "redis");
var app = builder.Build();
app.MapHealthChecks("/health");
app.Run();Cookie Security Configuration Deep-Dive
ธงของ cookie ที่เรากำหนดค่าด้านบนไม่ใช่ตัวเลือก — พวกเขาเป็นขอบเขตความปลอดภัยที่สำคัญ:
httpOnly Flag
httpOnly: true // CRITICAL
สิ่งที่ทำ: ป้องกันไม่ให้ JavaScript เข้าถึง cookie ผ่านทาง document.cookie เพื่อป้องกันการโจมตี XSS (Cross-Site Scripting)
เหตุที่มีความสำคัญ: ผู้โจมตีที่ฉีด JavaScript ที่เป็นอันตรายอยู่ไม่สามารถขโมย session ID ได้หากเป็น httpOnly นี่คือเหตุผลที่การจัดเก็บ token ใน localStorage เป็นอันตราย — พวกเขาสามารถเข้าถึงได้จาก JavaScript และเสี่ยงต่อ XSS
// BAD: Not httpOnly - vulnerable to XSS
cookie: { httpOnly: false }
// Attacker can do: document.cookie // Extracts session ID
// GOOD: httpOnly - secure against XSS
cookie: { httpOnly: true }
// Attacker cannot access with: document.cookie
Secure Flag
secure: process.env.NODE_ENV === 'production' // CRITICAL
สิ่งที่ทำ: รับประกันว่า cookie ถูกส่งผ่าน HTTPS เท่านั้น ป้องกันการโจมตี man-in-the-middle (MITM)
เหตุที่มีความสำคัญ: หากไม่มีธง secure cookie อาจถูกส่งไปแบบไม่เข้ารหัสผ่าน HTTP ซึ่งผู้โจมตีเครือข่ายสามารถสกัดได้
SameSite Flag
sameSite: 'strict' as const
ตัวเลือก:
strict: ส่ง cookie เฉพาะกับคำขออยู่ในไซต์เดียวกัน (ปลอดภัยที่สุด แต่ทำลายกรณีการใช้งานที่ถูกต้องบางกรณี)lax: ส่ง cookie ด้วยการนำทางระดับบนสุดจากเว็บไซต์อื่น แต่ไม่มีคำขออยู่ในไซต์ข้าม (สมดุล)none: ส่ง cookie ในบริบททั้งหมด (ต้อง secure flag ใช้สำหรับสถานการณ์โดเมนข้าม)
เหตุที่มีความสำคัญ: ป้องกันการโจมตี CSRF (Cross-Site Request Forgery) โดยที่ผู้โจมตีหลอกเบราว์เซอร์ของคุณให้ทำการร้องขอในนามพวกเขา
// Strict example: User on evil.com cannot trigger requests to yourapp.com
// that include the session cookie
cookie: { sameSite: 'strict' }
// Lax example: Links from other sites work, but form submissions don't
cookie: { sameSite: 'lax' }
Domain and Path
domain: '.example.com' // Restricts to example.com and subdomains
path: '/' // Available to all paths
การจำกัดโดเมนป้องกันไม่ให้ cookie ถูกใช้ในโดเมนย่อยที่ไม่เกี่ยวข้อง
Session Storage Strategies
backends การจัดเก็บข้อมูลต่างกันมีข้อดีและข้อเสียต่างกัน:
1. In-Memory Storage (Express.MemoryStore)
// NOT RECOMMENDED for production
store: new session.MemoryStore()
ข้อดี:
- ตั้งค่าง่าย
- เร็ว (ไม่มี I/O)
- เหมาะสำหรับการพัฒนา/ทดสอบ
ข้อเสีย:
- session จะหายไปเมื่อ server รีสตาร์ท
- ไม่แบ่งปันข้าม server instance หลายตัว
- หน่วยความจำเพิ่มขึ้นโดยไม่มีขอบเขต
- ไม่เหมาะสำหรับการใช้งานจริง
2. Redis Store (Recommended)
store: new RedisStore({ client: redisClient })
ข้อดี:
- ยังคงอยู่ข้ามการรีสตาร์ท server
- แบ่งปันข้าม server instance หลายตัว
- เร็วมาก (in-memory database)
- การหมดอายุอัตโนมัติ (TTL support)
- การปรับขนาดแนวนอนที่ง่าย
- สร้างสำหรับกรณีการใช้งานนี้
ข้อเสีย:
- ต้องการ Redis infrastructure
- บริการเพิ่มเติมในการดำเนินการ
- ตั้งค่าที่ซับซ้อนกว่าเล็กน้อย
ตั้งค่าการใช้งานจริง:
// src/config/sessionConfig.ts (extended)
export const redisClient = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
password: process.env.REDIS_PASSWORD,
socket: {
reconnectStrategy: (retries) => {
if (retries > 10) {
console.error('Redis reconnection failed');
process.exit(1);
}
return Math.min(retries * 100, 3000); // Cap at 3 seconds
},
connectTimeout: 10000,
keepAlive: 30000,
noDelay: true, // Disable Nagle's algorithm for lower latency
},
});
// Enable Redis authentication and TLS in production
if (process.env.NODE_ENV === 'production') {
// Handled via REDIS_URL with format: redis://username:password@host:port
}@Bean
public LettuceConnectionFactory productionRedisFactory() {
RedisStandaloneConfiguration cfg = new RedisStandaloneConfiguration();
cfg.setHostName(System.getenv("REDIS_HOST"));
cfg.setPort(Integer.parseInt(System.getenv().getOrDefault("REDIS_PORT", "6379")));
if (System.getenv("REDIS_PASSWORD") != null)
cfg.setPassword(System.getenv("REDIS_PASSWORD"));
LettuceClientConfiguration clientCfg = LettuceClientConfiguration.builder()
.commandTimeout(Duration.ofSeconds(10))
.shutdownTimeout(Duration.ZERO)
.build();
return new LettuceConnectionFactory(cfg, clientCfg);
}import redis.asyncio as aioredis
import os
redis_client = aioredis.from_url(
os.environ["REDIS_URL"],
password = os.getenv("REDIS_PASSWORD"),
socket_timeout = 10,
socket_keepalive = True,
retry_on_timeout = True,
health_check_interval = 30,
)builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
// Redis URL format: host:port,password=xxx,ssl=true,connectTimeout=10000
});
// For HA with Sentinel:
// options.Configuration = "sentinel-host:26379,serviceName=mymaster,password=xxx";3. Database Store (PostgreSQL/MongoDB)
// Example: connect-mongo for MongoDB
import MongoStore from 'connect-mongo';
store: new MongoStore({
mongoUrl: process.env.MONGODB_URI,
touchAfter: 24 * 3600, // Lazy session update (every 24 hours)
})// Spring Session with JPA (PostgreSQL / any relational DB)
// Add dependency: spring-session-jdbc
@Configuration
@EnableJdbcHttpSession(maxInactiveIntervalInSeconds = 86400)
public class JdbcSessionConfig {
// Spring auto-creates the SPRING_SESSION table on first run
// (or use the provided schema SQL for production)
}# SQLAlchemy-backed session store (PostgreSQL / SQLite)
# Use sqlalchemy-session or a custom implementation
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
# Store session in a `sessions` table:
# CREATE TABLE sessions (
# id VARCHAR PRIMARY KEY,
# data JSONB,
# expires_at TIMESTAMPTZ
# );
async def save_session_db(session_id: str, data: dict, ttl: int, db: AsyncSession):
import json
from datetime import datetime, timedelta, timezone
expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl)
await db.execute(
sa.text("INSERT INTO sessions (id, data, expires_at) VALUES (:id, :data, :exp) "
"ON CONFLICT (id) DO UPDATE SET data = :data, expires_at = :exp"),
{"id": session_id, "data": json.dumps(data), "exp": expires_at},
)// ASP.NET Core with Entity Framework Core session store
// Add dependency: Microsoft.AspNetCore.Session (already included)
// For database-backed sessions, use a custom IDistributedCache backed by EF:
builder.Services.AddDbContext<SessionDbContext>(opts =>
opts.UseNpgsql(builder.Configuration.GetConnectionString("Postgres")));
builder.Services.AddDistributedSqlServerCache(options =>
{
options.ConnectionString = builder.Configuration.GetConnectionString("SqlServer");
options.SchemaName = "dbo";
options.TableName = "Sessions";
});ข้อดี:
- ยังคงอยู่
- เทคโนโลยีที่คุ้นเคย
- ดีสำหรับการค้นหาที่ซับซ้อน
- ใช้ได้ข้าม instance
ข้อเสีย:
- ช้ากว่า Redis
- การเขียนเข้าฐานข้อมูลมากขึ้น
- ต้องการงาน cleanup ฐานข้อมูล
- มากเกินไปสำหรับกรณีการใช้งานส่วนใหญ่
คำแนะนำ: ใช้ Redis สำหรับ 99% ของกรณี ใช้ database store เฉพาะเมื่อคุณมีการค้นหา session ที่ซับซ้อนแล้วหรือไม่ต้องการบริการอื่น
Scaling Sessions Across Multiple Server Instances
เมื่อคุณมี server หลายตัว คุณมีสองตัวเลือก:
Option 1: Sticky Sessions (Not Recommended)
Load Balancer
↓
┌───────────────┬───────────────┬───────────────┐
│ Server 1 │ Server 2 │ Server 3 │
│ (Memory) │ (Memory) │ (Memory) │
└───────────────┴───────────────┴───────────────┘
User 1: Always routed to Server 1
User 2: Always routed to Server 2
User 3: Always routed to Server 3
ปัญหา:
- ความล้มเหลวของ server = การสูญเสีย session
- การกระจายโหลดไม่สมดุล
- ตั้งค่า load balancer ที่ซับซ้อน
- deployment ที่ยาก (ไม่สามารถระบายการเชื่อมต่อได้)
Option 2: Shared Session Store (Recommended)
Load Balancer
↓
┌───────────────┬───────────────┬───────────────┐
│ Server 1 │ Server 2 │ Server 3 │
└───────────────┴───────────────┴───────────────┘
↓ ↓ ↓
┌─────────────────────────────────────────────┐
│ Redis Session Store │
│ ┌──────────────────────────────────────┐ │
│ │ sessionId1: { userId, role, ... } │ │
│ │ sessionId2: { userId, role, ... } │ │
│ │ sessionId3: { userId, role, ... } │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
ข้อดี:
- server ใดก็ได้สามารถจัดการ request ใดก็ได้
- ความล้มเหลวของ server ไม่ส่งผลต่อ session
- ปรับขนาดแนวนอนได้ง่าย
- ตั้งค่า load balancer ที่ง่าย
การใช้งาน:
// All servers point to same Redis instance
store: new RedisStore({
client: redisClient,
prefix: 'session:', // Namespace sessions
})
// For Redis HA, use Redis Sentinel or Cluster
const redisClient = createClient({
url: 'redis-sentinel://localhost:26379',
// Or for cluster:
// url: 'redis-cluster://localhost:7000,localhost:7001,localhost:7002'
});// All Spring Boot instances share the same Redis session store
// configured via @EnableRedisHttpSession (see Session Configuration above).
// For Redis Sentinel HA:
@Bean
public LettuceConnectionFactory sentinelRedisFactory() {
RedisSentinelConfiguration sentinelCfg = new RedisSentinelConfiguration()
.master("mymaster")
.sentinel("sentinel1", 26379)
.sentinel("sentinel2", 26379);
return new LettuceConnectionFactory(sentinelCfg);
}# All FastAPI instances share the same Redis session store
# (redis_client defined in sessionConfig points to shared Redis)
# For Redis Sentinel:
redis_client = aioredis.from_url(
"redis://sentinel1:26379,sentinel2:26379/0?sentinel=mymaster"
)
# For Redis Cluster:
from redis.asyncio.cluster import RedisCluster
redis_client = RedisCluster.from_url("redis://cluster-node1:7000")// All ASP.NET Core instances use the same Redis cache for sessions.
// Configure via connection string in appsettings.json:
// "Redis": "node1:6379,node2:6379,node3:6379,abortConnect=false"
// For Sentinel:
builder.Services.AddStackExchangeRedisCache(options =>
{
options.ConfigurationOptions = new ConfigurationOptions
{
EndPoints = { "sentinel1:26379", "sentinel2:26379" },
ServiceName = "mymaster",
AbortOnConnectFail = false,
};
});CSRF Protection with Session Cookies
CSRF (Cross-Site Request Forgery) เป็นข้อกังวลที่สำคัญเกี่ยวกับการตรวจสอบ session โดยทำงานดังนี้:
1. User logs into yourapp.com
Browser receives: Set-Cookie: sessionId=abc123
2. User visits evil.com WITHOUT logging out
evil.com HTML contains: <form action="https://yourapp.com/api/transfer-money" method="POST">
3. Form auto-submits with JavaScript
Browser automatically includes: Cookie: sessionId=abc123
Server thinks: "Oh, valid session, this must be a legitimate request"
Money gets transferred!
Prevention Strategy 1: SameSite Cookies
cookie: {
sameSite: 'strict' // Cookies not sent on cross-site requests
}
สิ่งนี้บล็อกการโจมตี CSRF เพราะ session cookie จะไม่ถูกส่งเมื่อ evil.com พยายามส่ง request ไปยัง yourapp.com
Prevention Strategy 2: CSRF Tokens
สำหรับการป้องกันเพิ่มเติม ให้ใช้ CSRF token:
import csrf from 'csurf';
const csrfProtection = csrf({ cookie: false });
// Server generates token
app.get('/api/csrf-token', csrfProtection, (req, res) => {
res.json({ csrfToken: csrfProtection(req, res) });
});
// Client includes token in form
app.post('/api/protected-action', csrfProtection, (req, res) => {
// Token is validated automatically by csrf middleware
// Request is rejected if token is missing or invalid
res.json({ success: true });
});import org.springframework.security.web.csrf.*;
// Spring Security provides CSRF protection out-of-the-box.
// Enable it in SecurityConfig (it is ON by default for stateful apps):
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
// or: .csrfTokenRepository(HttpSessionCsrfTokenRepository())
);
return http.build();
}
// Client must include X-XSRF-TOKEN header (or _csrf field) in mutating requests.
// Spring Security rejects requests with missing/invalid CSRF tokens automatically.from fastapi import Request, HTTPException
import secrets
CSRF_TOKEN_KEY = "csrf_token"
async def get_csrf_token(request: Request) -> str:
"""Return (or create) a CSRF token stored in the session."""
session_id = request.cookies.get("sessionId")
if not session_id:
raise HTTPException(status_code=401, detail="Not authenticated")
session = await get_session(session_id)
if CSRF_TOKEN_KEY not in session:
session[CSRF_TOKEN_KEY] = secrets.token_hex(32)
await save_session(session_id, session)
return session[CSRF_TOKEN_KEY]
async def verify_csrf(request: Request) -> None:
"""Raise 403 if the CSRF token in the request header does not match the session."""
session_id = request.cookies.get("sessionId")
session = await get_session(session_id or "")
expected = session.get(CSRF_TOKEN_KEY) if session else None
received = request.headers.get("X-CSRF-Token")
if not expected or not secrets.compare_digest(expected, received or ""):
raise HTTPException(status_code=403, detail="CSRF token validation failed")
@app.get("/api/csrf-token")
async def csrf_token_endpoint(request: Request):
token = await get_csrf_token(request)
return {"csrfToken": token}
@app.post("/api/protected-action")
async def protected_action(request: Request, _=Depends(verify_csrf)):
return {"success": True}// ASP.NET Core includes built-in CSRF (Antiforgery) support.
builder.Services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-Token";
options.Cookie.Name = "XSRF-TOKEN";
});
// In controllers:
[ApiController]
public class CsrfController : ControllerBase
{
private readonly IAntiforgery _antiforgery;
public CsrfController(IAntiforgery antiforgery)
{
_antiforgery = antiforgery;
}
[HttpGet("api/csrf-token")]
public IActionResult GetToken()
{
var tokens = _antiforgery.GetAndStoreTokens(HttpContext);
return Ok(new { csrfToken = tokens.RequestToken });
}
[HttpPost("api/protected-action")]
[ValidateAntiForgeryToken] // Automatically validates X-CSRF-Token header
public IActionResult ProtectedAction()
{
return Ok(new { success = true });
}
}วิธีการทำงาน:
- Server สร้าง CSRF token เฉพาะต่อ session
- Client ต้องรวม token นี้ใน request body (ไม่ใช่ cookie)
- evil.com ไม่สามารถอ่าน token ได้ (SOP - Same Origin Policy)
- การส่งแบบฟอร์มล้มเหลวหากไม่มี token ที่ถูกต้อง
Complete CSRF Protection Setup
// src/middleware/csrf.ts
import csrf from 'csurf';
import { Request, Response, NextFunction } from 'express';
export const csrfProtection = csrf({ cookie: false });
export const csrfErrorHandler = (
err: any,
req: Request,
res: Response,
next: NextFunction
) => {
if (err.code === 'EBADCSRFTOKEN') {
res.status(403).json({ error: 'CSRF token validation failed' });
} else {
next(err);
}
};
// Usage in app
app.use(csrfErrorHandler);
// Get token endpoint
app.get('/api/csrf-token', csrfProtection, (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
// Protected endpoints
app.post('/api/sensitive-action', csrfProtection, (req, res) => {
// CSRF token is automatically validated
res.json({ message: 'Action completed safely' });
});import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.csrf.*;
@Configuration
public class CsrfConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
)
.exceptionHandling(ex -> ex
.accessDeniedHandler((req, res, accessDeniedException) -> {
if (accessDeniedException.getMessage().contains("CSRF")) {
res.setStatus(403);
res.getWriter().write("{\"error\":\"CSRF token validation failed\"}");
}
})
);
return http.build();
}
}
@RestController
public class CsrfController {
@GetMapping("/api/csrf-token")
public ResponseEntity<?> getCsrfToken(CsrfToken token) {
return ResponseEntity.ok(Map.of("csrfToken", token.getToken()));
}
@PostMapping("/api/sensitive-action")
public ResponseEntity<?> sensitiveAction() {
// Spring Security validates X-XSRF-TOKEN automatically
return ResponseEntity.ok(Map.of("message", "Action completed safely"));
}
}from fastapi import FastAPI, Depends, Request
from fastapi.middleware.cors import CORSMiddleware
# CSRF middleware (using the helpers defined in Prevention Strategy 2)
@app.exception_handler(Exception)
async def csrf_exception_handler(request: Request, exc: Exception):
if "CSRF" in str(exc):
return JSONResponse(status_code=403, content={"error": "CSRF token validation failed"})
raise exc
@app.get("/api/csrf-token")
async def get_csrf(request: Request):
token = await get_csrf_token(request)
return {"csrfToken": token}
@app.post("/api/sensitive-action")
async def sensitive_action(_=Depends(verify_csrf)):
return {"message": "Action completed safely"}// Complete antiforgery setup (extends the snippet from Strategy 2)
// Program.cs
builder.Services.AddAntiforgery(opts =>
{
opts.HeaderName = "X-CSRF-Token";
opts.Cookie.Name = "XSRF-TOKEN";
opts.Cookie.SecurePolicy = CookieSecurePolicy.Always;
opts.Cookie.SameSite = SameSiteMode.Strict;
});
// Middleware to expose CSRF token in a cookie for SPA clients
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/api"))
{
var antiforgery = context.RequestServices.GetRequiredService<IAntiforgery>();
var tokens = antiforgery.GetAndStoreTokens(context);
context.Response.Cookies.Append("XSRF-TOKEN", tokens.RequestToken!,
new CookieOptions { HttpOnly = false });
}
await next();
});
// Global exception filter for antiforgery errors
public class AntiforgeryExceptionFilter : IExceptionFilter
{
public void OnException(ExceptionContext context)
{
if (context.Exception is AntiforgeryValidationException)
{
context.Result = new JsonResult(new { error = "CSRF token validation failed" })
{
StatusCode = StatusCodes.Status403Forbidden,
};
context.ExceptionHandled = true;
}
}
}Session Fixation Attacks and Prevention
Session fixation เป็นการโจมตีซึ่งผู้โจมตีบังคับให้ผู้ใช้ใช้ session ID ที่รู้จักกันดี
Attack Scenario
1. Attacker creates a session by visiting yourapp.com
Gets session: sessionId=abc123
2. Attacker tricks user into using this session
Sends link: yourapp.com/?jsessionid=abc123
Or sets cookie via JavaScript
3. User logs in using the attacker's session ID
Server updates session with user's data
Attacker now has access (session ID is known)
Prevention: Session Regeneration on Login
app.post('/api/auth/login', async (req, res) => {
// Validate credentials
const user = await UserService.verifyCredentials(email, password);
if (!user) return res.status(401).json({ error: 'Invalid' });
// CRITICAL: Regenerate session ID after login
req.session.regenerate((err) => {
if (err) {
console.error('Session regeneration failed:', err);
return res.status(500).json({ error: 'Login failed' });
}
// Old session ID is invalid
// New session ID is created
req.session.userId = user.id;
req.session.loginTime = Date.now();
res.json({ message: 'Login successful' });
});
});@PostMapping("/api/auth/login")
public ResponseEntity<?> login(
@RequestBody LoginRequest body,
HttpServletRequest request
) {
User user = userService.verifyCredentials(body.email(), body.password());
if (user == null)
return ResponseEntity.status(401).body(Map.of("error", "Invalid credentials"));
// CRITICAL: Invalidate the old session and create a new one
HttpSession oldSession = request.getSession(false);
if (oldSession != null) oldSession.invalidate();
HttpSession newSession = request.getSession(true);
newSession.setAttribute("userId", user.id());
newSession.setAttribute("loginTime", System.currentTimeMillis());
return ResponseEntity.ok(Map.of("message", "Login successful"));
}@app.post("/api/auth/login")
async def login(body: LoginRequest, request: Request, response: Response):
user = await UserService.verify_credentials(body.email, body.password)
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
# CRITICAL: Delete old session and issue a new session ID
old_session_id = request.cookies.get("sessionId")
if old_session_id:
await delete_session(old_session_id)
new_session_id = secrets.token_hex(32)
await save_session(new_session_id, {
"user_id": user.id,
"login_time": datetime.now(timezone.utc).timestamp(),
})
response.set_cookie("sessionId", new_session_id, httponly=True, secure=True,
samesite="strict", max_age=86400)
return {"message": "Login successful"}[HttpPost("api/auth/login")]
public async Task<IActionResult> Login([FromBody] LoginRequest body)
{
var user = await _userService.VerifyCredentialsAsync(body.Email, body.Password);
if (user is null)
return Unauthorized(new { error = "Invalid credentials" });
// CRITICAL: Clear existing session data (regenerates session ID on next request)
HttpContext.Session.Clear();
// Store user data in the new session
HttpContext.Session.SetString("userId", user.Id);
HttpContext.Session.SetString("loginTime",
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString());
return Ok(new { message = "Login successful" });
}ภายใต้ประทุน:
- express-session ทำลาย session เก่า
- session ใหม่ที่มี ID ใหม่ถูกสร้าง
- session ID ที่รู้จักของผู้โจมตีตอนนี้ไม่ถูกต้อง
- เฉพาะผู้ใช้เท่านั้นที่รู้ session ID ใหม่
Additional Protections
// Validate IP address consistency
const validateSessionIntegrity = (req: Request) => {
const session = req.session.userData;
if (!session) return true;
// Check if IP has changed drastically
const currentIP = req.ip;
if (session.ipAddress && session.ipAddress !== currentIP) {
// Log suspicious activity
console.warn(`IP change detected for user ${session.userId}`);
// Could require re-authentication
}
return true;
};
// Validate User-Agent consistency
const validateUserAgent = (req: Request) => {
const session = req.session.userData;
const currentAgent = req.get('User-Agent');
if (session?.userAgent && session.userAgent !== currentAgent) {
console.warn(`User-Agent change detected`);
// Could indicate session hijacking
}
};import jakarta.servlet.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
public class SessionIntegrityInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler
) throws Exception {
HttpSession session = request.getSession(false);
if (session == null) return true;
String storedIp = (String) session.getAttribute("ipAddress");
String currentIp = request.getRemoteAddr();
if (storedIp != null && !storedIp.equals(currentIp)) {
// Log suspicious IP change
System.out.printf("IP change detected for session %s: %s -> %s%n",
session.getId(), storedIp, currentIp);
// Optionally invalidate session:
// session.invalidate();
// response.sendError(401, "Session integrity check failed");
// return false;
}
String storedAgent = (String) session.getAttribute("userAgent");
String currentAgent = request.getHeader("User-Agent");
if (storedAgent != null && !storedAgent.equals(currentAgent)) {
System.out.printf("User-Agent change detected for session %s%n", session.getId());
}
return true;
}
}from fastapi import Request
import logging
logger = logging.getLogger(__name__)
async def validate_session_integrity(request: Request) -> None:
"""Log warnings when IP or User-Agent changes mid-session."""
session_id = request.cookies.get("sessionId")
if not session_id:
return
session = await get_session(session_id)
if not session:
return
current_ip = request.client.host if request.client else None
current_agent = request.headers.get("user-agent")
if session.get("ip_address") and session["ip_address"] != current_ip:
logger.warning("IP change detected for user %s", session.get("user_id"))
if session.get("user_agent") and session["user_agent"] != current_agent:
logger.warning("User-Agent change detected for session %s", session_id)public class SessionIntegrityMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<SessionIntegrityMiddleware> _logger;
public SessionIntegrityMiddleware(
RequestDelegate next,
ILogger<SessionIntegrityMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var userId = context.Session.GetString("userId");
if (userId is not null)
{
var storedIp = context.Session.GetString("ipAddress");
var currentIp = context.Connection.RemoteIpAddress?.ToString();
var storedAgent = context.Session.GetString("userAgent");
var currentAgent = context.Request.Headers["User-Agent"].ToString();
if (storedIp is not null && storedIp != currentIp)
_logger.LogWarning("IP change detected for user {UserId}", userId);
if (storedAgent is not null && storedAgent != currentAgent)
_logger.LogWarning("User-Agent change detected for session {SessionId}",
context.Session.Id);
}
await _next(context);
}
}Implementing Logout and Session Invalidation
Logout ที่เหมาะสมมีความสำคัญต่อความปลอดภัย นี่คือการใช้งานที่ครอบคลุม:
// src/routes/auth.ts
import { Router, Request, Response } from 'express';
const router = Router();
router.post('/logout', (req: Request, res: Response) => {
const userId = req.session.userId;
const sessionId = req.sessionID;
// Log the logout action (important for audit trails)
console.log(`User ${userId} logged out. Session: ${sessionId}`);
// Destroy session on server
req.session.destroy((err) => {
if (err) {
console.error('Session destruction failed:', err);
return res.status(500).json({ error: 'Logout failed' });
}
// Clear the session cookie from client
res.clearCookie('sessionId', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
});
res.json({
message: 'Logout successful',
timestamp: new Date().toISOString(),
});
});
});
// Logout from all devices (invalidate all sessions for a user)
router.post('/logout-all-devices', async (req: Request, res: Response) => {
if (!req.session.userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
const userId = req.session.userId;
const currentSessionId = req.sessionID;
try {
// In production, you'd iterate through Redis keys and delete
// For now, we'll demonstrate the pattern:
// const keys = await redisClient.keys(`session:*`);
// for (const key of keys) {
// const session = await redisClient.get(key);
// if (session) {
// const data = JSON.parse(session);
// if (data.userId === userId) {
// await redisClient.del(key);
// }
// }
// }
// For Redis, a better approach is to track sessions per user
// and invalidate them in bulk
console.log(`All sessions invalidated for user: ${userId}`);
// Destroy current session
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.clearCookie('sessionId', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
});
res.json({
message: 'All sessions invalidated. Please log in again.',
});
});
} catch (error) {
console.error('Logout all devices failed:', error);
res.status(500).json({ error: 'Logout failed' });
}
});
export default router;import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.*;
import java.time.Instant;
@RestController
@RequestMapping("/api/auth")
public class LogoutController {
private final SessionTrackingService sessionTracking;
public LogoutController(SessionTrackingService sessionTracking) {
this.sessionTracking = sessionTracking;
}
@PostMapping("/logout")
public ResponseEntity<?> logout(
HttpServletRequest request,
HttpServletResponse response
) {
HttpSession session = request.getSession(false);
if (session != null) {
String userId = (String) session.getAttribute("userId");
String sessionId = session.getId();
// Audit log
System.out.printf("User %s logged out. Session: %s%n", userId, sessionId);
session.invalidate();
}
// Clear the cookie
Cookie cookie = new Cookie("sessionId", "");
cookie.setMaxAge(0);
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setSecure(true);
response.addCookie(cookie);
return ResponseEntity.ok(Map.of(
"message", "Logout successful",
"timestamp", Instant.now().toString()
));
}
@PostMapping("/logout-all-devices")
public ResponseEntity<?> logoutAllDevices(
HttpServletRequest request,
HttpServletResponse response
) {
HttpSession session = request.getSession(false);
if (session == null)
return ResponseEntity.status(401).body(Map.of("error", "Not authenticated"));
String userId = (String) session.getAttribute("userId");
// Invalidate all sessions for this user tracked in Redis
sessionTracking.invalidateAllForUser(userId);
session.invalidate();
Cookie cookie = new Cookie("sessionId", "");
cookie.setMaxAge(0);
cookie.setPath("/");
response.addCookie(cookie);
return ResponseEntity.ok(Map.of("message", "All sessions invalidated. Please log in again."));
}
}from fastapi import APIRouter, Request, Response, HTTPException, Depends
import logging
router = APIRouter(prefix="/api/auth")
logger = logging.getLogger(__name__)
@router.post("/logout")
async def logout(request: Request, response: Response):
session_id = request.cookies.get("sessionId")
if session_id:
session = await get_session(session_id)
logger.info("User %s logged out. Session: %s",
session.get("user_id") if session else "unknown", session_id)
await delete_session(session_id)
response.delete_cookie("sessionId", path="/", secure=True,
httponly=True, samesite="strict")
return {"message": "Logout successful",
"timestamp": datetime.now(timezone.utc).isoformat()}
@router.post("/logout-all-devices")
async def logout_all_devices(request: Request, response: Response,
session: dict = Depends(require_auth)):
user_id = session["user_id"]
# Delete all sessions for this user from Redis
# (requires tracking user -> session IDs, e.g. via a Redis set)
session_keys = await redis_client.smembers(f"user:{user_id}:sessions")
if session_keys:
await redis_client.delete(*session_keys)
await redis_client.delete(f"user:{user_id}:sessions")
logger.info("All sessions invalidated for user: %s", user_id)
current_session_id = request.cookies.get("sessionId", "")
await delete_session(current_session_id)
response.delete_cookie("sessionId", path="/", secure=True,
httponly=True, samesite="strict")
return {"message": "All sessions invalidated. Please log in again."}[ApiController]
[Route("api/auth")]
public class LogoutController : ControllerBase
{
private readonly ISessionTrackingService _sessionTracking;
private readonly ILogger<LogoutController> _logger;
public LogoutController(
ISessionTrackingService sessionTracking,
ILogger<LogoutController> logger)
{
_sessionTracking = sessionTracking;
_logger = logger;
}
[HttpPost("logout")]
public IActionResult Logout()
{
var userId = HttpContext.Session.GetString("userId");
var sessionId = HttpContext.Session.Id;
_logger.LogInformation("User {UserId} logged out. Session: {SessionId}",
userId, sessionId);
HttpContext.Session.Clear();
Response.Cookies.Delete("sessionId", new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
Path = "/",
});
return Ok(new { message = "Logout successful",
timestamp = DateTime.UtcNow.ToString("O") });
}
[HttpPost("logout-all-devices")]
[Authorize]
public async Task<IActionResult> LogoutAllDevices()
{
var userId = HttpContext.Session.GetString("userId");
if (userId is null)
return Unauthorized(new { error = "Not authenticated" });
// Invalidate all sessions for this user in Redis
await _sessionTracking.InvalidateAllForUserAsync(userId);
HttpContext.Session.Clear();
Response.Cookies.Delete("sessionId", new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
});
return Ok(new { message = "All sessions invalidated. Please log in again." });
}
}Session Revocation Pattern
สำหรับระบบการใช้งานจริง ให้บันทึกรายการเพิกถอน:
// src/config/sessionRevocation.ts
import { redisClient } from './sessionConfig';
const REVOCATION_PREFIX = 'revoked:';
/**
* Revoke a specific session
*/
export async function revokeSession(sessionId: string): Promise<void> {
const key = `${REVOCATION_PREFIX}${sessionId}`;
// Set with 24-hour expiration to prevent memory bloat
await redisClient.setEx(key, 24 * 60 * 60, '1');
}
/**
* Revoke all sessions for a user
*/
export async function revokeUserSessions(userId: string): Promise<void> {
const key = `user:${userId}:sessions`;
// Mark all sessions as revoked by incrementing a counter
await redisClient.incr(key);
}
/**
* Check if session is revoked
*/
export async function isSessionRevoked(sessionId: string): Promise<boolean> {
const key = `${REVOCATION_PREFIX}${sessionId}`;
const revoked = await redisClient.exists(key);
return revoked === 1;
}
/**
* Middleware to check revocation status
*/
export const checkRevocation = async (req: any, res: any, next: any) => {
if (!req.session.userId) {
return next();
}
const isRevoked = await isSessionRevoked(req.sessionID);
if (isRevoked) {
req.session.destroy((err: any) => {
res.clearCookie('sessionId');
return res.status(401).json({ error: 'Session was revoked' });
});
} else {
next();
}
};import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
public class SessionRevocationService {
private static final String REVOCATION_PREFIX = "revoked:";
private final StringRedisTemplate redis;
public SessionRevocationService(StringRedisTemplate redis) {
this.redis = redis;
}
/** Mark a session as revoked (TTL 24 h). */
public void revokeSession(String sessionId) {
redis.opsForValue().set(
REVOCATION_PREFIX + sessionId, "1", Duration.ofHours(24));
}
/** Increment a per-user revocation counter (invalidates all user sessions). */
public void revokeUserSessions(String userId) {
redis.opsForValue().increment("user:" + userId + ":revocation");
}
/** Return true if the session is revoked. */
public boolean isSessionRevoked(String sessionId) {
return Boolean.TRUE.equals(redis.hasKey(REVOCATION_PREFIX + sessionId));
}
}
/** Filter that rejects revoked sessions before they reach controllers. */
@Component
public class RevocationCheckFilter extends OncePerRequestFilter {
private final SessionRevocationService revocationService;
public RevocationCheckFilter(SessionRevocationService revocationService) {
this.revocationService = revocationService;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain
) throws ServletException, java.io.IOException {
HttpSession session = request.getSession(false);
if (session != null && session.getAttribute("userId") != null) {
if (revocationService.isSessionRevoked(session.getId())) {
session.invalidate();
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\":\"Session was revoked\"}");
return;
}
}
chain.doFilter(request, response);
}
}import redis.asyncio as aioredis
REVOCATION_PREFIX = "revoked:"
async def revoke_session(session_id: str) -> None:
"""Mark a session as revoked (24-hour TTL)."""
await redis_client.setex(f"{REVOCATION_PREFIX}{session_id}", 24 * 60 * 60, "1")
async def revoke_user_sessions(user_id: str) -> None:
"""Increment a per-user revocation counter."""
await redis_client.incr(f"user:{user_id}:revocation")
async def is_session_revoked(session_id: str) -> bool:
"""Return True if the session has been revoked."""
return await redis_client.exists(f"{REVOCATION_PREFIX}{session_id}") == 1
async def check_revocation(request: Request, call_next):
"""Middleware: reject revoked sessions."""
session_id = request.cookies.get("sessionId")
if session_id and await is_session_revoked(session_id):
await delete_session(session_id)
response = Response(
content='{"error":"Session was revoked"}',
status_code=401,
media_type="application/json",
)
response.delete_cookie("sessionId")
return response
return await call_next(request)public class SessionRevocationService
{
private readonly IConnectionMultiplexer _redis;
private const string RevocationPrefix = "revoked:";
public SessionRevocationService(IConnectionMultiplexer redis)
{
_redis = redis;
}
public async Task RevokeSessionAsync(string sessionId)
{
var db = _redis.GetDatabase();
await db.StringSetAsync(RevocationPrefix + sessionId, "1",
TimeSpan.FromHours(24));
}
public async Task RevokeUserSessionsAsync(string userId)
{
var db = _redis.GetDatabase();
await db.StringIncrementAsync($"user:{userId}:revocation");
}
public async Task<bool> IsSessionRevokedAsync(string sessionId)
{
var db = _redis.GetDatabase();
return await db.KeyExistsAsync(RevocationPrefix + sessionId);
}
}
public class RevocationCheckMiddleware
{
private readonly RequestDelegate _next;
private readonly SessionRevocationService _revocation;
public RevocationCheckMiddleware(
RequestDelegate next,
SessionRevocationService revocation)
{
_next = next;
_revocation = revocation;
}
public async Task InvokeAsync(HttpContext context)
{
var sessionId = context.Session.Id;
var userId = context.Session.GetString("userId");
if (userId is not null && await _revocation.IsSessionRevokedAsync(sessionId))
{
context.Session.Clear();
context.Response.Cookies.Delete("sessionId");
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new { error = "Session was revoked" });
return;
}
await _next(context);
}
}Production Checklist
นี่คือรายการตรวจสอบที่ครอบคลุมก่อนที่จะปรับใช้ไปยังการใช้งานจริง:
Security Configuration
// ✓ HTTPS/TLS
// Ensure all servers run behind HTTPS
cookie: {
secure: true, // Only send over HTTPS
}
// ✓ Cookie Flags
cookie: {
httpOnly: true, // Prevent XSS token theft
secure: true, // HTTPS only
sameSite: 'strict', // Prevent CSRF
maxAge: 24 * 60 * 60 * 1000, // 24 hours
}
// ✓ Session Secret
// Use a strong, random secret
secret: process.env.SESSION_SECRET, // From environment, not hardcoded
// Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
// ✓ CSRF Protection
// Implement CSRF tokens for state-changing operations
const csrfProtection = csrf({ cookie: false });
// ✓ Session Regeneration
// Regenerate on login
req.session.regenerate((err) => {
// Set user data
});
// ✓ Rate Limiting on Auth Endpoints
import rateLimit from 'express-rate-limit';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 requests per window
message: 'Too many login attempts, please try again later',
skip: (req) => process.env.NODE_ENV !== 'production',
});
app.post('/api/auth/login', loginLimiter, async (req, res) => {
// ...
});import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ✓ HTTPS enforcement
.requiresChannel(channel -> channel.anyRequest().requiresSecure())
// ✓ Session management
.sessionManagement(sm -> sm
.sessionFixation().newSession() // Regenerate on login
.maximumSessions(10) // Limit concurrent sessions
.maxSessionsPreventsLogin(false)
)
// ✓ CSRF protection (on by default for stateful apps)
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
// ✓ Headers
.headers(h -> h
.httpStrictTransportSecurity(hsts -> hsts.includeSubDomains(true).maxAgeInSeconds(31536000))
.frameOptions(f -> f.deny())
);
return http.build();
}
}
// ✓ Rate limiting with Bucket4j
@Component
public class LoginRateLimitFilter extends OncePerRequestFilter {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, java.io.IOException {
if (req.getServletPath().equals("/api/auth/login")) {
Bucket bucket = buckets.computeIfAbsent(req.getRemoteAddr(),
ip -> Bucket.builder()
.addLimit(Bandwidth.classic(5, Refill.intervally(5, Duration.ofMinutes(15))))
.build());
if (!bucket.tryConsume(1)) {
res.setStatus(429);
res.getWriter().write("{\"error\":\"Too many login attempts\"}");
return;
}
}
chain.doFilter(req, res);
}
}from slowapi import Limiter
from slowapi.util import get_remote_address
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
limiter = Limiter(key_func=get_remote_address)
# ✓ HTTPS enforcement (in production via reverse proxy / cloud)
if os.getenv("ENVIRONMENT") == "production":
app.add_middleware(HTTPSRedirectMiddleware)
# ✓ Rate limiting on login endpoint (5 requests / 15 minutes per IP)
@app.post("/api/auth/login")
@limiter.limit("5/15 minutes")
async def login(request: Request, body: LoginRequest, response: Response):
user = await UserService.verify_credentials(body.email, body.password)
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
session_id = secrets.token_hex(32)
await save_session(session_id, {"user_id": user.id})
response.set_cookie("sessionId", session_id, httponly=True, secure=True,
samesite="strict", max_age=86400)
return {"message": "Login successful"}
# ✓ Strong session secret (from environment)
SESSION_SECRET = os.environ["SESSION_SECRET"]
# Generate: python -c "import secrets; print(secrets.token_hex(32))"// Program.cs — production-grade security configuration
// ✓ HTTPS enforcement
builder.Services.AddHttpsRedirection(options =>
{
options.HttpsPort = 443;
});
// ✓ HSTS
builder.Services.AddHsts(options =>
{
options.Preload = true;
options.IncludeSubDomains = true;
options.MaxAge = TimeSpan.FromDays(365);
});
// ✓ Rate limiting (.NET 7+)
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("login", limiter =>
{
limiter.PermitLimit = 5;
limiter.Window = TimeSpan.FromMinutes(15);
limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiter.QueueLimit = 0;
});
});
// Apply rate limiter to login endpoint:
app.MapPost("/api/auth/login", LoginHandler)
.RequireRateLimiting("login");
// ✓ Session secret from environment (data protection key ring)
// Managed automatically via AddDataProtection() configured above.
// Set SESSION_SECRET env var and persist keys to Redis or Azure Key Vault.Operational Configuration
// ✓ Redis Configuration
const redisClient = createClient({
url: process.env.REDIS_URL,
socket: {
reconnectStrategy: (retries) => {
if (retries > 10) {
console.error('Redis connection lost');
process.exit(1); // Graceful shutdown
}
return Math.min(retries * 50, 500);
},
},
});
// ✓ Session Rotation
// Sessions expire and force re-login periodically
cookie: {
maxAge: 24 * 60 * 60 * 1000, // 24 hours
}
// ✓ Secure Session Name
name: 'sessionId', // Don't use defaults like 'connect.sid'
// ✓ Proxy Configuration
proxy: process.env.NODE_ENV === 'production', // Trust proxy headers
// ✓ Environment Variables
// All secrets in .env
SESSION_SECRET=<strong-random-string>
REDIS_URL=redis://host:port
REDIS_PASSWORD=<password>
NODE_ENV=production# application-production.properties
# ✓ Redis configuration
spring.data.redis.url=${REDIS_URL}
spring.data.redis.password=${REDIS_PASSWORD}
spring.data.redis.ssl.enabled=true
spring.data.redis.timeout=10s
# ✓ Session configuration
spring.session.redis.namespace=session
spring.session.timeout=24h
spring.session.redis.flush-mode=on-save
# ✓ Cookie settings (applied in SessionConfig @Bean)
server.servlet.session.cookie.name=sessionId
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=strict
server.servlet.session.cookie.max-age=86400
# ✓ Proxy / forwarded headers
server.forward-headers-strategy=native# .env (loaded via python-dotenv)
# SESSION_SECRET=<strong-random-string>
# REDIS_URL=redis://:password@host:6379/0
# ENVIRONMENT=production
import os
from dotenv import load_dotenv
load_dotenv()
SESSION_SECRET = os.environ["SESSION_SECRET"]
REDIS_URL = os.environ["REDIS_URL"]
SESSION_TTL = 24 * 60 * 60 # 24 hours
# ✓ Secure session cookie name
SESSION_COOKIE_NAME = "sessionId" # Not 'session' or other obvious defaults
# ✓ Trust proxy in production (configure via Uvicorn / reverse proxy)
# uvicorn app:app --proxy-headers --forwarded-allow-ips='*'// appsettings.Production.json
// {
// "ConnectionStrings": {
// "Redis": "host:6379,password=xxx,ssl=true,abortConnect=false"
// },
// "Session": {
// "IdleTimeoutHours": 24,
// "CookieName": "sessionId"
// }
// }
// Program.cs — read from config
builder.Services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromHours(
builder.Configuration.GetValue<int>("Session:IdleTimeoutHours", 24));
options.Cookie.Name = builder.Configuration["Session:CookieName"] ?? "sessionId";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.IsEssential = true;
});
// ✓ Forward proxy headers (behind nginx / load balancer)
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
});Monitoring and Logging
// src/middleware/sessionMonitoring.ts
import { Request, Response, NextFunction } from 'express';
export const sessionMonitoring = (
req: Request,
res: Response,
next: NextFunction
) => {
const startTime = Date.now();
// Capture session metrics
if (req.session.userId) {
const duration = Date.now() - startTime;
// Log to monitoring system
console.log({
timestamp: new Date().toISOString(),
userId: req.session.userId,
sessionId: req.sessionID,
path: req.path,
method: req.method,
duration,
statusCode: res.statusCode,
ipAddress: req.ip,
});
}
next();
};
// Application setup
app.use(sessionMonitoring);
// Alert on unusual activity
export const detectSuspiciousActivity = async (req: Request) => {
const session = req.session.userData;
// Check for impossible travel (IP change in impossible time)
// Check for unusual user agents
// Check for spike in failed login attempts
// Send alerts to monitoring system
};import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.*;
@Component
public class SessionMonitoringInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(SessionMonitoringInterceptor.class);
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
HttpSession session = req.getSession(false);
if (session != null && session.getAttribute("userId") != null) {
req.setAttribute("_startTime", System.currentTimeMillis());
}
return true;
}
@Override
public void afterCompletion(
HttpServletRequest req, HttpServletResponse res, Object handler, Exception ex
) {
HttpSession session = req.getSession(false);
if (session == null || session.getAttribute("userId") == null) return;
long startTime = (Long) req.getAttribute("_startTime");
long duration = System.currentTimeMillis() - startTime;
log.info("session_request userId={} sessionId={} path={} method={} status={} duration={}ms ip={}",
session.getAttribute("userId"),
session.getId(),
req.getRequestURI(),
req.getMethod(),
res.getStatus(),
duration,
req.getRemoteAddr()
);
}
}import logging
import time
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger(__name__)
class SessionMonitoringMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
start_time = time.perf_counter()
response = await call_next(request)
duration = (time.perf_counter() - start_time) * 1000 # ms
session_id = request.cookies.get("sessionId")
if session_id:
session = await get_session(session_id)
if session and "user_id" in session:
logger.info(
"session_request",
extra={
"user_id": session["user_id"],
"session_id": session_id,
"path": request.url.path,
"method": request.method,
"status": response.status_code,
"duration_ms": round(duration, 2),
"ip": request.client.host if request.client else None,
},
)
return response
app.add_middleware(SessionMonitoringMiddleware)using Microsoft.AspNetCore.Http;
using System.Diagnostics;
public class SessionMonitoringMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<SessionMonitoringMiddleware> _logger;
public SessionMonitoringMiddleware(
RequestDelegate next,
ILogger<SessionMonitoringMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var sw = Stopwatch.StartNew();
await _next(context);
sw.Stop();
var userId = context.Session.GetString("userId");
if (userId is not null)
{
_logger.LogInformation(
"session_request UserId={UserId} SessionId={SessionId} " +
"Path={Path} Method={Method} Status={Status} Duration={Duration}ms IP={IP}",
userId,
context.Session.Id,
context.Request.Path,
context.Request.Method,
context.Response.StatusCode,
sw.ElapsedMilliseconds,
context.Connection.RemoteIpAddress
);
}
}
}
// Register in Program.cs:
// app.UseMiddleware<SessionMonitoringMiddleware>();Testing Checklist
// Test cases to verify before production
describe('Session Authentication', () => {
// ✓ Session creation on login
// ✓ Session persistence across requests
// ✓ Session destruction on logout
// ✓ Session timeout
// ✓ Session regeneration on login
// ✓ CSRF token validation
// ✓ httpOnly cookie prevents XSS
// ✓ Secure flag on HTTPS
// ✓ sameSite prevents CSRF
// ✓ Cross-device logout works
// ✓ Session failover with Redis
// ✓ Rate limiting on login
// ✓ Lockout after failed attempts
});
Deployment Checklist
- HTTPS configured with valid certificate
- SESSION_SECRET set to strong random value
- Redis cluster/sentinel configured for HA
- Rate limiting configured on auth endpoints
- CSRF tokens implemented
- Monitoring and alerts configured
- Backup strategy for session data (if using database)
- Graceful shutdown handlers implemented
- Load balancer sticky sessions disabled
- Cookie domain/path properly configured
- Session timeout values reviewed
- Audit logging configured
- Security headers added (HSTS, etc.)
- Log aggregation set up
- Incident response plan in place
Conclusion
Session-based authentication ยังคงเป็นวิธีการตรวจสอบสิทธิ์ที่ได้รับการทดลองและสูบน้ำหลายครั้ง เมื่อนำไปใช้อย่างถูกต้องด้วย:
- Cryptographically secure session IDs
- Server-side session storage (ควรใช้ Redis)
- Proper cookie configuration (httpOnly, secure, sameSite)
- Session regeneration on login (ป้องกัน fixation)
- CSRF protection (token + SameSite)
- Shared session stores (เพื่อการปรับขนาดแนวนอน)
- Comprehensive monitoring (ตรวจจับการโจมตี)
Session-based auth ให้คุณสมบัติความปลอดภัยที่แข็งแกร่ง ความแตกต่างหลักจากวิธีการตรวจสอบโดยใช้ token คือ server ยังคงรักษาการควบคุมแบบเต็มบนสถานะการตรวจสอบสิทธิ์ ทำให้เหมาะสำหรับแอปพลิเคชันที่มีความปลอดภัยสูงและระบบที่ต้องการการเพิกถอน session ทันที
รูปแบบการใช้งานที่แสดงที่นี่ — โดยเฉพาะ Redis store, CSRF protection และกลยุทธ์การเพิกถอน — แสดงถึงแนวปฏิบัติระดับการใช้งานจริงที่ใช้ในแอปพลิเคชันเว็บขนาดใหญ่ทั่วโลก