กลับไปที่บทความ
Authentication Security Backend Node.js

Session-Based Authentication กลยุทธ์การยืนยันตัวตนสำหรับแอปเว็บสมัยใหม่

พลากร วรมงคล
9 เมษายน 2568 12 นาที

“คู่มือการใช้งาน session-based authentication ที่ครอบคลุม — ครอบคลุมวิธีการทำงานของ session ที่ระดับพื้นฐาน กลยุทธ์การจัดเก็บข้อมูลฝั่งเซิร์ฟเวอร์ ความปลอดภัยของ cookie การปรับขนาดด้วย Redis และแนวปฏิบัติที่ดีที่สุดสำหรับการใช้งานจริง”

เจาะลึก: 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 จะ:

  1. ดึง session ID จาก cookie
  2. ค้นหา session ใน store
  3. ตรวจสอบ session (วันหมดอายุ ตรวจสอบความปลอดภัย)
  4. แนบข้อมูล session เข้ากับ request object
  5. ประมวลผล 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 ที่เรากำหนดค่าด้านบนไม่ใช่ตัวเลือก — พวกเขาเป็นขอบเขตความปลอดภัยที่สำคัญ:

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 หลายตัว
  • หน่วยความจำเพิ่มขึ้นโดยไม่มีขอบเขต
  • ไม่เหมาะสำหรับการใช้งานจริง
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 หลายตัว คุณมีสองตัวเลือก:

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 ที่ยาก (ไม่สามารถระบายการเชื่อมต่อได้)
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 });
    }
}

วิธีการทำงาน:

  1. Server สร้าง CSRF token เฉพาะต่อ session
  2. Client ต้องรวม token นี้ใน request body (ไม่ใช่ cookie)
  3. evil.com ไม่สามารถอ่าน token ได้ (SOP - Same Origin Policy)
  4. การส่งแบบฟอร์มล้มเหลวหากไม่มี 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" });
}

ภายใต้ประทุน:

  1. express-session ทำลาย session เก่า
  2. session ใหม่ที่มี ID ใหม่ถูกสร้าง
  3. session ID ที่รู้จักของผู้โจมตีตอนนี้ไม่ถูกต้อง
  4. เฉพาะผู้ใช้เท่านั้นที่รู้ 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 ยังคงเป็นวิธีการตรวจสอบสิทธิ์ที่ได้รับการทดลองและสูบน้ำหลายครั้ง เมื่อนำไปใช้อย่างถูกต้องด้วย:

  1. Cryptographically secure session IDs
  2. Server-side session storage (ควรใช้ Redis)
  3. Proper cookie configuration (httpOnly, secure, sameSite)
  4. Session regeneration on login (ป้องกัน fixation)
  5. CSRF protection (token + SameSite)
  6. Shared session stores (เพื่อการปรับขนาดแนวนอน)
  7. Comprehensive monitoring (ตรวจจับการโจมตี)

Session-based auth ให้คุณสมบัติความปลอดภัยที่แข็งแกร่ง ความแตกต่างหลักจากวิธีการตรวจสอบโดยใช้ token คือ server ยังคงรักษาการควบคุมแบบเต็มบนสถานะการตรวจสอบสิทธิ์ ทำให้เหมาะสำหรับแอปพลิเคชันที่มีความปลอดภัยสูงและระบบที่ต้องการการเพิกถอน session ทันที

รูปแบบการใช้งานที่แสดงที่นี่ — โดยเฉพาะ Redis store, CSRF protection และกลยุทธ์การเพิกถอน — แสดงถึงแนวปฏิบัติระดับการใช้งานจริงที่ใช้ในแอปพลิเคชันเว็บขนาดใหญ่ทั่วโลก

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

เขียนโดย พลากร วรมงคล

Software Engineer Specialist ประสบการณ์กว่า 20 ปี เขียนเกี่ยวกับ Architecture, Performance และการสร้างระบบ Production

เพิ่มเติมเกี่ยวกับผม

บทความที่เกี่ยวข้อง