Authentication Security Backend Node.js

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

คู่มือการใช้งาน 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

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

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