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

การรับรองตัวตนแบบไม่ใช้รหัสผ่าน กลยุทธ์การยืนยันตัวตนสำหรับแอปเว็บสมัยใหม่

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

“คู่มือการใช้งานที่ครอบคลุมสำหรับการรับรองตัวตนแบบไม่ใช้รหัสผ่าน ครอบคลุม magic email links, SMS OTP, WebAuthn/FIDO2 passkeys, รูปแบบการใช้งาน และการพิจารณาด้านความปลอดภัย”

การเจาะลึก: การรับรองตัวตนแบบไม่ใช้รหัสผ่าน

การรับรองตัวตนแบบ Passwordless คืออะไร

การรับรองตัวตนแบบ passwordless เป็นวิธีการที่ตรวจสอบตัวตนของผู้ใช้ โดยไม่ต้องใช้รหัสผ่าน แทนที่จะใช้ความลับที่ผู้ใช้ต้องจำไว้ ระบบ passwordless อาศัย possession factors (สิ่งของที่ผู้ใช้มี — กล่องจดหมายอีเมล โทรศัพท์ คีย์ฮาร์ดแวร์) หรือ inherence factors (สิ่งที่ผู้ใช้เป็น — ลายนิ้วมือ ใบหน้า) ผู้ใช้พิสูจน์ตัวตนโดยแสดงการควบคุมอุปกรณ์หรือช่องทางการสื่อสาร แทนที่จะจดจำสตริงที่ซับซ้อน

วิธี passwordless ที่มีอิทธิพลมากที่สุดสามวิธี ได้แก่ magic email links (URL ที่ลงนามส่งไปยังกล่องจดหมายของผู้ใช้) SMS/email one-time passwords (OTP) และ WebAuthn/FIDO2 passkeys (ข้อมูลประจำตัวแบบเข้ารหัสจัดเก็บไว้บนอุปกรณ์) แต่ละวิธีนำเสนอความสมดุลที่แตกต่างกันระหว่างความปลอดภัย ความสะดวกสบาย และความซับซ้อนในการใช้งาน

Passwordless ได้รับ momentum หลังจากที่ FIDO Alliance เผยแพร่มาตรฐาน WebAuthn ในปี 2019 และการใช้งานเพิ่มขึ้นอย่างรวดเร็วเมื่อ Apple, Google และ Microsoft ยอมรับการสนับสนุน passkey ข้ามแพลตฟอร์มของพวกเขาในปี 2022-2023

หลักการแกนกลาง

  • ไม่มีความลับที่ใช้ร่วมกัน: ไม่มีรหัสผ่านที่สามารถถูกขโมย phishing หรือ brute-force WebAuthn ข้อมูลประจำตัวไม่เคยข้ามเครือข่าย หรือเป็นแบบใช้ครั้งเดียวและมีกำหนดเวลา (magic links, OTP)
  • การตรวจสอบตัวตนโดยอิงจากการครอบครอง: การรับรองตัวตนเชื่อมโยงกับอุปกรณ์หรือบัญชีที่ผู้ใช้ควบคุม (อีเมล โทรศัพท์ คีย์ฮาร์ดแวร์)
  • ความต้านทาน phishing: WebAuthn/FIDO2 passkeys มีความต้านทาน phishing โดยธรรมชาติ — ข้อมูลประจำตัวจะถูกผูกมัดกับ origin และไม่สามารถนำมาใช้ซ้ำในโดเมนที่แตกต่างกันได้
  • ลดภาระการสนับสนุน: ไม่มีการรีเซ็ตรหัสผ่าน ไม่มีความต้องการความซับซ้อน ไม่มีขั้นตอน “forgot password”
sequenceDiagram
    participant U as User
    participant B as Browser
    participant S as Server
    participant E as Email Service
    participant DB as Database

    U->>B: Enter email address
    B->>S: POST /auth/magic-link {email}
    S->>S: Generate signed token (JWT, 15min expiry)
    S->>DB: Store token hash + email + expiry
    S->>E: Send email with magic link URL
    E-->>U: Email: "Click to sign in"
    S-->>B: "Check your email"

    U->>B: Click magic link (/auth/verify?token=...)
    B->>S: GET /auth/verify?token=abc123
    S->>DB: Lookup token hash, check expiry
    DB-->>S: Valid — email: user@example.com
    S->>DB: Delete used token (single-use)
    S->>S: Create session or issue JWT
    S-->>B: Set-Cookie / return tokens — Logged in

ตอนนี้ลองมาทำความเข้าใจว่าเหตุใดรหัสผ่านจึงยังคงเป็นปัญหา และวิธี passwordless แต่ละวิธีแก้ไขปัญหานี้อย่างไร

ปัญหาเรื่องรหัสผ่าน

ก่อนที่จะสำรวจวิธีแก้ไข เรามาทำความเข้าใจว่าเหตุใดรหัสผ่านจึงยังเป็นปัญหาแม้ว่าจะมีคำแนะนำด้านความปลอดภัยมาเป็นเวลาหลายทศวรรษ:

Credential Stuffing & Reuse: รายงาน Verizon Data Breach Investigations Report ปี 2023 พบว่า 49% ของการละเมิดข้อมูลเกี่ยวข้องกับข้อมูลประจำตัวที่ถูกขโมย เมื่อบริการหนึ่งถูกจัดการ ผู้โจมตีจะลองใช้ข้อมูลประจำตัวเดียวกันทุกแห่ง มนุษย์ไม่สามารถจัดการรหัสผ่านเฉพาะและซับซ้อนมากกว่า 100 รหัสผ่านได้

Phishing Vulnerability: รหัสผ่านเป็นข้อมูลประจำตัวที่ผู้ใช้รู้จัก ผู้ใช้สามารถถูกล่อลวงทางสังคมให้ป้อนลงในไซต์ที่เป็นอันตราย การโจมตี phishing ที่ประสงค์ร้ายนั้นเกือบจะเป็นไปไม่ได้ที่จะป้องกันได้เมื่อข้อมูลประจำตัวเป็นความลับที่ใช้ร่วมกัน

Brute Force Complexity: การบังคับใช้นโยบายรหัสผ่าน (ความยาว ความซับซ้อน การหมุนเวียน) สร้างความเสียดสีโดยไม่ได้รับผลประโยชน์ด้านความปลอดภัยที่เท่าเทียม ผู้ใช้ตอบสนองโดยเลือกรหัสผ่านที่อ่อนแอกว่าหรือใช้ซ้ำ

Credential Storage Risk: บริการต้องแฮชรหัสผ่านอย่างถูกต้อง การละเมิดพื้นที่เก็บข้อมูลใด ๆ ที่มีแฮชที่อ่อนแอจะเปิดเผยผู้ใช้ต่อการถ้อดรหัสแบบออฟไลน์ แม้แต่การแฮชรหัสผ่านที่ออกแบบมาอย่างดีก็ใช้ทรัพยากรการคำนวณที่ปรับขนาดได้ไม่ดี

การรับรองตัวตนแบบ passwordless จะข้ามปัญหาเหล่านี้ทั้งหมดโดยทำให้ปัจจัยการรับรองตัวตนเป็นสิ่งที่ผู้ใช้ครอบครอง (โทรศัพท์ อีเมล) หรือสิ่งที่พวกเขาเป็น (biometric)

Magic links เป็นวิธี passwordless ที่ไม่ยุ่งยากที่สุด: ผู้ใช้ขอลิงก์ผ่านอีเมล คลิก และพวกเขาได้รับการรับรองตัวตน ความลับคือลิงก์เหล่านี้เป็น tokens ที่ลงนามด้วยการเข้ารหัสโดยมีความถูกต้องตามช่วงเวลาสั้นๆ

  1. ผู้ใช้ป้อนอีเมลของพวกเขา
  2. แอปพลิเคชันสร้าง signed token ด้วยวันหมดอายุ
  3. ส่งอีเมลที่มีลิงก์พร้อมกับ token
  4. ผู้ใช้คลิกลิงก์
  5. แอปพลิเคชันตรวจสอบลายเซ็นของ token และวันหมดอายุ
  6. สร้างเซสชัน

การใช้งานในสภาวะจริง

นี่คือการใช้งานคุณภาพโปรดักชั่นที่สมบูรณ์โดยใช้ Express.js และ TypeScript:

import crypto from 'crypto';
import { Router, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import nodemailer from 'nodemailer';
import { prisma } from '@/lib/prisma';
import { logger } from '@/lib/logger';

// Configuration
const MAGIC_LINK_SECRET = process.env.MAGIC_LINK_SECRET!;
const MAGIC_LINK_EXPIRY = 15 * 60 * 1000; // 15 minutes
const MAX_REQUESTS_PER_HOUR = 5;
const BASE_URL = process.env.BASE_URL!;

// Email configuration
const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: parseInt(process.env.SMTP_PORT || '587'),
  secure: process.env.SMTP_SECURE === 'true',
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS,
  },
});

interface MagicLinkPayload {
  email: string;
  iat: number;
  exp: number;
}

/**
 * Rate limiter for magic link requests
 * Prevents abuse by limiting requests per email per hour
 */
async function checkRateLimit(email: string): Promise<boolean> {
  const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
  
  const recentAttempts = await prisma.magicLinkAttempt.count({
    where: {
      email: email.toLowerCase(),
      createdAt: { gte: oneHourAgo },
    },
  });

  return recentAttempts < MAX_REQUESTS_PER_HOUR;
}

/**
 * Record a magic link attempt for rate limiting
 */
async function recordMagicLinkAttempt(email: string): Promise<void> {
  await prisma.magicLinkAttempt.create({
    data: {
      email: email.toLowerCase(),
      createdAt: new Date(),
    },
  });
}

/**
 * Generate a cryptographically signed magic link token
 */
function generateMagicLinkToken(email: string): string {
  const payload: MagicLinkPayload = {
    email: email.toLowerCase(),
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + MAGIC_LINK_EXPIRY / 1000,
  };

  return jwt.sign(payload, MAGIC_LINK_SECRET, {
    algorithm: 'HS256',
  });
}

/**
 * Verify a magic link token and extract the email
 */
function verifyMagicLinkToken(token: string): { email: string } | null {
  try {
    const decoded = jwt.verify(token, MAGIC_LINK_SECRET, {
      algorithms: ['HS256'],
    }) as MagicLinkPayload;

    return { email: decoded.email };
  } catch (error) {
    logger.warn('Invalid magic link token', { error });
    return null;
  }
}

/**
 * Send magic link email
 */
async function sendMagicLinkEmail(
  email: string,
  token: string
): Promise<void> {
  const magicLinkUrl = `${BASE_URL}/auth/callback?token=${encodeURIComponent(token)}`;

  const htmlContent = `
    <h2>Your Magic Link</h2>
    <p>Click the link below to sign in to your account:</p>
    <p>
      <a href="${magicLinkUrl}" style="background: #0066cc; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px;">
        Sign In
      </a>
    </p>
    <p>Or copy and paste this link in your browser:</p>
    <p><code>${magicLinkUrl}</code></p>
    <p>This link expires in 15 minutes.</p>
    <p>If you didn't request this link, you can safely ignore this email.</p>
  `;

  try {
    await transporter.sendMail({
      from: process.env.SMTP_FROM!,
      to: email,
      subject: 'Your Magic Sign-In Link',
      html: htmlContent,
      text: `Sign in here: ${magicLinkUrl}\n\nThis link expires in 15 minutes.`,
    });

    logger.info('Magic link email sent', { email });
  } catch (error) {
    logger.error('Failed to send magic link email', { email, error });
    throw new Error('Failed to send email. Please try again.');
  }
}

/**
 * Express route to request a magic link
 */
export async function requestMagicLink(req: Request, res: Response) {
  const { email } = req.body;

  // Validation
  if (!email || typeof email !== 'string') {
    return res.status(400).json({ error: 'Email is required' });
  }

  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(email)) {
    return res.status(400).json({ error: 'Invalid email format' });
  }

  try {
    // Check rate limit
    const withinLimit = await checkRateLimit(email);
    if (!withinLimit) {
      return res.status(429).json({
        error: 'Too many requests. Please try again in an hour.',
      });
    }

    // Record attempt for rate limiting
    await recordMagicLinkAttempt(email);

    // Generate token
    const token = generateMagicLinkToken(email);

    // Send email
    await sendMagicLinkEmail(email, token);

    // Return success (don't leak whether email exists)
    return res.status(200).json({
      message: 'Check your email for a sign-in link',
    });
  } catch (error) {
    logger.error('Error requesting magic link', { error });
    return res.status(500).json({
      error: 'An error occurred. Please try again.',
    });
  }
}

/**
 * Express route to verify magic link and create session
 */
export async function verifyMagicLink(req: Request, res: Response) {
  const { token } = req.query;

  if (!token || typeof token !== 'string') {
    return res.status(400).json({ error: 'Token is required' });
  }

  // Verify token
  const decoded = verifyMagicLinkToken(token);
  if (!decoded) {
    return res.status(401).json({
      error: 'Invalid or expired link. Please request a new one.',
    });
  }

  try {
    // Find or create user
    let user = await prisma.user.findUnique({
      where: { email: decoded.email },
    });

    if (!user) {
      user = await prisma.user.create({
        data: {
          email: decoded.email,
          name: decoded.email.split('@')[0],
        },
      });
    }

    // Create session
    const sessionToken = crypto.randomBytes(32).toString('hex');
    await prisma.session.create({
      data: {
        token: sessionToken,
        userId: user.id,
        expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
      },
    });

    // Set secure, httpOnly cookie
    res.cookie('sessionToken', sessionToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 30 * 24 * 60 * 60 * 1000,
      path: '/',
    });

    logger.info('User authenticated via magic link', { userId: user.id });

    return res.status(200).json({
      message: 'Authenticated successfully',
      user: { id: user.id, email: user.email, name: user.name },
    });
  } catch (error) {
    logger.error('Error verifying magic link', { error });
    return res.status(500).json({
      error: 'An error occurred. Please try again.',
    });
  }
}
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import java.security.SecureRandom;
import java.time.*;
import java.util.*;

@RestController
@RequestMapping("/auth")
public class MagicLinkController {

    private static final int MAGIC_LINK_EXPIRY_MINUTES = 15;
    private static final int MAX_REQUESTS_PER_HOUR = 5;

    @Autowired private UserRepository userRepo;
    @Autowired private SessionRepository sessionRepo;
    @Autowired private MagicLinkAttemptRepository attemptRepo;
    @Autowired private JavaMailSender mailSender;
    @Value("${magic.link.secret}") private String secret;
    @Value("${app.base-url}") private String baseUrl;

    private boolean checkRateLimit(String email) {
        Instant oneHourAgo = Instant.now().minusSeconds(3600);
        long count = attemptRepo.countByEmailAndCreatedAtAfter(
            email.toLowerCase(), oneHourAgo);
        return count < MAX_REQUESTS_PER_HOUR;
    }

    private String generateMagicLinkToken(String email) {
        return Jwts.builder()
            .subject(email.toLowerCase())
            .issuedAt(new Date())
            .expiration(Date.from(Instant.now().plusSeconds(MAGIC_LINK_EXPIRY_MINUTES * 60)))
            .signWith(Keys.hmacShaKeyFor(secret.getBytes()))
            .compact();
    }

    private Optional<String> verifyMagicLinkToken(String token) {
        try {
            Claims claims = Jwts.parser()
                .verifyWith(Keys.hmacShaKeyFor(secret.getBytes()))
                .build()
                .parseSignedClaims(token)
                .getPayload();
            return Optional.of(claims.getSubject());
        } catch (JwtException e) {
            return Optional.empty();
        }
    }

    private void sendMagicLinkEmail(String email, String token) {
        String magicLinkUrl = baseUrl + "/auth/callback?token=" + token;
        SimpleMailMessage msg = new SimpleMailMessage();
        msg.setTo(email);
        msg.setSubject("Your Magic Sign-In Link");
        msg.setText("Sign in here: " + magicLinkUrl + "\n\nThis link expires in 15 minutes.");
        mailSender.send(msg);
    }

    @PostMapping("/magic-link")
    public ResponseEntity<Map<String, String>> requestMagicLink(
            @RequestBody Map<String, String> body) {
        String email = body.get("email");
        if (email == null || !email.matches("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")) {
            return ResponseEntity.badRequest()
                .body(Map.of("error", "Valid email is required"));
        }

        if (!checkRateLimit(email)) {
            return ResponseEntity.status(429)
                .body(Map.of("error", "Too many requests. Please try again in an hour."));
        }

        attemptRepo.save(new MagicLinkAttempt(email.toLowerCase()));
        String token = generateMagicLinkToken(email);
        sendMagicLinkEmail(email, token);

        return ResponseEntity.ok(Map.of("message", "Check your email for a sign-in link"));
    }

    @GetMapping("/callback")
    public ResponseEntity<Map<String, Object>> verifyMagicLink(
            @RequestParam String token,
            HttpServletResponse response) {
        Optional<String> emailOpt = verifyMagicLinkToken(token);
        if (emailOpt.isEmpty()) {
            return ResponseEntity.status(401)
                .body(Map.of("error", "Invalid or expired link. Please request a new one."));
        }

        String email = emailOpt.get();
        User user = userRepo.findByEmail(email).orElseGet(() ->
            userRepo.save(User.builder()
                .email(email)
                .name(email.split("@")[0])
                .build()));

        SecureRandom sr = new SecureRandom();
        byte[] tokenBytes = new byte[32];
        sr.nextBytes(tokenBytes);
        String sessionToken = HexFormat.of().formatHex(tokenBytes);

        sessionRepo.save(Session.builder()
            .token(sessionToken)
            .userId(user.getId())
            .expiresAt(Instant.now().plus(30, ChronoUnit.DAYS))
            .build());

        ResponseCookie cookie = ResponseCookie.from("sessionToken", sessionToken)
            .httpOnly(true).secure(true).sameSite("Lax")
            .maxAge(Duration.ofDays(30)).path("/").build();
        response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

        return ResponseEntity.ok(Map.of(
            "message", "Authenticated successfully",
            "user", Map.of("id", user.getId(), "email", user.getEmail())));
    }
}
import hashlib
import secrets
import re
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel, EmailStr
import jwt
from sqlalchemy.orm import Session

router = APIRouter()

MAGIC_LINK_SECRET = settings.MAGIC_LINK_SECRET
MAGIC_LINK_EXPIRY_MINUTES = 15
MAX_REQUESTS_PER_HOUR = 5
BASE_URL = settings.BASE_URL


def check_rate_limit(db: Session, email: str) -> bool:
    one_hour_ago = datetime.now(timezone.utc) - timedelta(hours=1)
    count = (db.query(MagicLinkAttempt)
        .filter(MagicLinkAttempt.email == email.lower(),
                MagicLinkAttempt.created_at >= one_hour_ago)
        .count())
    return count < MAX_REQUESTS_PER_HOUR


def generate_magic_link_token(email: str) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "email": email.lower(),
        "iat": int(now.timestamp()),
        "exp": int((now + timedelta(minutes=MAGIC_LINK_EXPIRY_MINUTES)).timestamp()),
    }
    return jwt.encode(payload, MAGIC_LINK_SECRET, algorithm="HS256")


def verify_magic_link_token(token: str) -> dict | None:
    try:
        return jwt.decode(token, MAGIC_LINK_SECRET, algorithms=["HS256"])
    except jwt.PyJWTError:
        return None


async def send_magic_link_email(email: str, token: str) -> None:
    magic_link_url = f"{BASE_URL}/auth/callback?token={token}"
    # Use your email provider (SendGrid, SES, etc.)
    await email_service.send(
        to=email,
        subject="Your Magic Sign-In Link",
        html=f'<a href="{magic_link_url}">Sign In</a> — expires in 15 minutes.',
        text=f"Sign in here: {magic_link_url}\n\nThis link expires in 15 minutes.",
    )


class MagicLinkRequest(BaseModel):
    email: EmailStr


@router.post("/auth/magic-link")
async def request_magic_link(
    body: MagicLinkRequest,
    db: Session = Depends(get_db),
):
    if not check_rate_limit(db, body.email):
        raise HTTPException(status_code=429,
            detail="Too many requests. Please try again in an hour.")

    db.add(MagicLinkAttempt(email=body.email.lower()))
    db.commit()

    token = generate_magic_link_token(body.email)
    await send_magic_link_email(body.email, token)

    return {"message": "Check your email for a sign-in link"}


@router.get("/auth/callback")
async def verify_magic_link(
    token: str,
    response: Response,
    db: Session = Depends(get_db),
):
    decoded = verify_magic_link_token(token)
    if not decoded:
        raise HTTPException(status_code=401,
            detail="Invalid or expired link. Please request a new one.")

    email = decoded["email"]
    user = db.query(User).filter_by(email=email).first()
    if not user:
        user = User(email=email, name=email.split("@")[0])
        db.add(user)
        db.commit()

    session_token = secrets.token_hex(32)
    db.add(Session(
        token=session_token,
        user_id=user.id,
        expires_at=datetime.now(timezone.utc) + timedelta(days=30),
    ))
    db.commit()

    response.set_cookie("sessionToken", session_token,
        httponly=True, secure=True, samesite="lax",
        max_age=30 * 24 * 60 * 60, path="/")

    return {"message": "Authenticated successfully",
            "user": {"id": user.id, "email": user.email, "name": user.name}}
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.IdentityModel.Tokens;

[ApiController]
[Route("auth")]
public class MagicLinkController : ControllerBase
{
    private const int MagicLinkExpiryMinutes = 15;
    private const int MaxRequestsPerHour = 5;

    private readonly AppDbContext _db;
    private readonly IEmailService _email;
    private readonly IConfiguration _config;

    public MagicLinkController(AppDbContext db, IEmailService email,
        IConfiguration config)
    { _db = db; _email = email; _config = config; }

    private async Task<bool> CheckRateLimitAsync(string email)
    {
        var oneHourAgo = DateTimeOffset.UtcNow.AddHours(-1);
        var count = await _db.MagicLinkAttempts.CountAsync(a =>
            a.Email == email.ToLower() && a.CreatedAt >= oneHourAgo);
        return count < MaxRequestsPerHour;
    }

    private string GenerateMagicLinkToken(string email)
    {
        var key = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(_config["MagicLink:Secret"]!));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
        var token = new JwtSecurityToken(
            claims: new[] { new Claim(ClaimTypes.Email, email.ToLower()) },
            expires: DateTime.UtcNow.AddMinutes(MagicLinkExpiryMinutes),
            signingCredentials: creds);
        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    private ClaimsPrincipal? VerifyMagicLinkToken(string token)
    {
        try
        {
            var handler = new JwtSecurityTokenHandler();
            return handler.ValidateToken(token, new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(
                    Encoding.UTF8.GetBytes(_config["MagicLink:Secret"]!)),
                ValidateIssuer = false,
                ValidateAudience = false,
                ClockSkew = TimeSpan.Zero,
            }, out _);
        }
        catch { return null; }
    }

    [HttpPost("magic-link")]
    public async Task<IActionResult> RequestMagicLink(
        [FromBody] MagicLinkRequest body)
    {
        if (!await CheckRateLimitAsync(body.Email))
            return StatusCode(429, new { error = "Too many requests. Please try again in an hour." });

        _db.MagicLinkAttempts.Add(new MagicLinkAttempt {
            Email = body.Email.ToLower(),
            CreatedAt = DateTimeOffset.UtcNow,
        });
        await _db.SaveChangesAsync();

        var token = GenerateMagicLinkToken(body.Email);
        var magicLinkUrl = $"{_config["App:BaseUrl"]}/auth/callback?token={Uri.EscapeDataString(token)}";
        await _email.SendAsync(body.Email, "Your Magic Sign-In Link",
            $"<a href='{magicLinkUrl}'>Sign In</a> — expires in 15 minutes.");

        return Ok(new { message = "Check your email for a sign-in link" });
    }

    [HttpGet("callback")]
    public async Task<IActionResult> VerifyMagicLink([FromQuery] string token)
    {
        var principal = VerifyMagicLinkToken(token);
        if (principal == null)
            return Unauthorized(new { error = "Invalid or expired link. Please request a new one." });

        var email = principal.FindFirstValue(ClaimTypes.Email)!;
        var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email)
            ?? _db.Users.Add(new User {
                Email = email, Name = email.Split('@')[0]
            }).Entity;
        await _db.SaveChangesAsync();

        var sessionToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLower();
        _db.Sessions.Add(new Session {
            Token = sessionToken, UserId = user.Id,
            ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
        });
        await _db.SaveChangesAsync();

        Response.Cookies.Append("sessionToken", sessionToken, new CookieOptions {
            HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax,
            MaxAge = TimeSpan.FromDays(30), Path = "/",
        });

        return Ok(new { message = "Authenticated successfully",
            user = new { user.Id, user.Email, user.Name } });
    }
}

Database Schema

// Prisma schema
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  sessions  Session[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Session {
  id        String   @id @default(cuid())
  token     String   @unique
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  expiresAt DateTime
  createdAt DateTime @default(now())
}

model MagicLinkAttempt {
  id        String   @id @default(cuid())
  email     String
  createdAt DateTime @default(now())

  @@index([email, createdAt])
}

คุณสมบัติด้านความปลอดภัยที่สำคัญ

  • JWT with HS256: Tokens ลงนามด้วยการเข้ารหัสและไม่สามารถการปลอมแปลงได้
  • Short Expiry: หน้าต่าง 15 นาที จำกัดความเสี่ยงหากอีเมลถูกจัดการ
  • Rate Limiting: ป้องกันการใช้ประโยชน์และความพยายามในการบังคับดุน
  • Secure Cookies: Session tokens ใช้แฟล็ก httpOnly และ secure
  • Email Normalization: ป้องกันปัญหาความไวต่อตัวพิมพ์เล็ก-ใหญ่

SMS OTP (One-Time Password)

SMS OTP ให้ความมั่นใจที่สูงขึ้นเนื่องจากต้องมีการครอบครองหมายเลขโทรศัพท์จริง ไม่ใช่เพียงการเข้าถึงบัญชีอีเมล อย่างไรก็ตาม มีราคาแพงกว่าและมีความเสี่ยงต่อการสลับ SIM

การใช้งานในสภาวะจริง

import crypto from 'crypto';
import { Router, Request, Response } from 'express';
import twilio from 'twilio';
import { prisma } from '@/lib/prisma';
import { logger } from '@/lib/logger';

const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID!;
const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN!;
const TWILIO_PHONE = process.env.TWILIO_PHONE!;

const OTP_LENGTH = 6;
const OTP_EXPIRY = 10 * 60 * 1000; // 10 minutes
const MAX_ATTEMPTS = 3; // Failed verification attempts
const MAX_REQUESTS_PER_HOUR = 3; // Request rate limit

const twilioClient = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN);

/**
 * Generate a cryptographically random OTP
 */
function generateOTP(): string {
  const maxValue = Math.pow(10, OTP_LENGTH) - 1;
  const randomValue = crypto.randomInt(0, maxValue + 1);
  return String(randomValue).padStart(OTP_LENGTH, '0');
}

/**
 * Check rate limit for OTP requests per phone number
 */
async function checkOTPRequestLimit(phone: string): Promise<boolean> {
  const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);

  const recentRequests = await prisma.otpAttempt.count({
    where: {
      phone: normalizePhone(phone),
      createdAt: { gte: oneHourAgo },
      verified: false,
    },
  });

  return recentRequests < MAX_REQUESTS_PER_HOUR;
}

/**
 * Normalize phone number to E.164 format
 */
function normalizePhone(phone: string): string {
  // Remove all non-numeric characters
  const cleaned = phone.replace(/\D/g, '');
  
  // If it's a US number without country code, add it
  if (cleaned.length === 10) {
    return `+1${cleaned}`;
  }
  
  if (!cleaned.startsWith('+')) {
    return `+${cleaned}`;
  }
  
  return cleaned;
}

/**
 * Store OTP with hashed value in database
 * We hash the OTP so a database breach doesn't expose active tokens
 */
async function storeOTP(phone: string): Promise<string> {
  const normalizedPhone = normalizePhone(phone);
  const otp = generateOTP();
  const otpHash = crypto.createHash('sha256').update(otp).digest('hex');

  await prisma.otpAttempt.create({
    data: {
      phone: normalizedPhone,
      otpHash,
      expiresAt: new Date(Date.now() + OTP_EXPIRY),
      failedAttempts: 0,
      verified: false,
    },
  });

  return otp;
}

/**
 * Send OTP via SMS
 */
async function sendOTPSMS(
  phone: string,
  otp: string
): Promise<void> {
  try {
    await twilioClient.messages.create({
      body: `Your sign-in code is: ${otp}\nDo not share this code with anyone.`,
      from: TWILIO_PHONE,
      to: normalizePhone(phone),
    });

    logger.info('OTP sent successfully', { phone: normalizePhone(phone) });
  } catch (error) {
    logger.error('Failed to send OTP SMS', { phone: normalizePhone(phone), error });
    throw new Error('Failed to send SMS. Please try again.');
  }
}

/**
 * Request OTP endpoint
 */
export async function requestOTP(req: Request, res: Response) {
  const { phone } = req.body;

  if (!phone || typeof phone !== 'string') {
    return res.status(400).json({ error: 'Phone number is required' });
  }

  try {
    const normalizedPhone = normalizePhone(phone);

    // Check rate limit
    const withinLimit = await checkOTPRequestLimit(phone);
    if (!withinLimit) {
      return res.status(429).json({
        error: 'Too many requests. Please try again later.',
      });
    }

    // Store OTP
    const otp = await storeOTP(phone);

    // Send SMS
    await sendOTPSMS(phone, otp);

    // Return masked phone for UI feedback
    const maskedPhone = normalizedPhone.slice(0, -4) + '****';

    return res.status(200).json({
      message: 'Code sent to your phone',
      phone: maskedPhone,
    });
  } catch (error) {
    logger.error('Error requesting OTP', { error });
    return res.status(500).json({
      error: 'Failed to send code. Please try again.',
    });
  }
}

/**
 * Verify OTP endpoint
 */
export async function verifyOTP(req: Request, res: Response) {
  const { phone, otp } = req.body;

  if (!phone || !otp) {
    return res.status(400).json({
      error: 'Phone number and code are required',
    });
  }

  try {
    const normalizedPhone = normalizePhone(phone);
    const otpHash = crypto.createHash('sha256').update(otp).digest('hex');

    // Find the OTP record
    const otpRecord = await prisma.otpAttempt.findFirst({
      where: {
        phone: normalizedPhone,
        verified: false,
        expiresAt: { gt: new Date() },
      },
      orderBy: { createdAt: 'desc' },
    });

    if (!otpRecord) {
      return res.status(401).json({
        error: 'Code not found or expired. Please request a new one.',
      });
    }

    // Check if too many failed attempts
    if (otpRecord.failedAttempts >= MAX_ATTEMPTS) {
      return res.status(429).json({
        error: 'Too many failed attempts. Please request a new code.',
      });
    }

    // Verify OTP using constant-time comparison
    const isValid = crypto.timingSafeEqual(
      Buffer.from(otpHash),
      Buffer.from(otpRecord.otpHash)
    );

    if (!isValid) {
      // Increment failed attempts
      await prisma.otpAttempt.update({
        where: { id: otpRecord.id },
        data: {
          failedAttempts: otpRecord.failedAttempts + 1,
        },
      });

      return res.status(401).json({
        error: 'Invalid code. Please try again.',
      });
    }

    // Mark as verified
    await prisma.otpAttempt.update({
      where: { id: otpRecord.id },
      data: { verified: true },
    });

    // Find or create user
    let user = await prisma.user.findUnique({
      where: { phone: normalizedPhone },
    });

    if (!user) {
      user = await prisma.user.create({
        data: {
          phone: normalizedPhone,
          email: undefined,
        },
      });
    }

    // Create session
    const sessionToken = crypto.randomBytes(32).toString('hex');
    await prisma.session.create({
      data: {
        token: sessionToken,
        userId: user.id,
        expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      },
    });

    res.cookie('sessionToken', sessionToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 30 * 24 * 60 * 60 * 1000,
      path: '/',
    });

    logger.info('User authenticated via OTP', { userId: user.id });

    return res.status(200).json({
      message: 'Authenticated successfully',
      user: { id: user.id, phone: user.phone },
    });
  } catch (error) {
    logger.error('Error verifying OTP', { error });
    return res.status(500).json({
      error: 'An error occurred. Please try again.',
    });
  }
}
import com.twilio.Twilio;
import com.twilio.rest.api.v2010.account.Message;
import com.twilio.type.PhoneNumber;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.*;
import java.time.*;
import java.util.*;

@RestController
@RequestMapping("/auth")
public class OtpController {

    private static final int OTP_LENGTH = 6;
    private static final int OTP_EXPIRY_MINUTES = 10;
    private static final int MAX_ATTEMPTS = 3;
    private static final int MAX_REQUESTS_PER_HOUR = 3;

    @Autowired private OtpAttemptRepository otpRepo;
    @Autowired private UserRepository userRepo;
    @Autowired private SessionRepository sessionRepo;
    @Value("${twilio.account-sid}") private String twilioSid;
    @Value("${twilio.auth-token}") private String twilioToken;
    @Value("${twilio.phone}") private String twilioPhone;

    private String generateOTP() {
        SecureRandom sr = new SecureRandom();
        int max = (int) Math.pow(10, OTP_LENGTH) - 1;
        return String.format("%0" + OTP_LENGTH + "d", sr.nextInt(max + 1));
    }

    private String normalizePhone(String phone) {
        String cleaned = phone.replaceAll("\\D", "");
        if (cleaned.length() == 10) return "+1" + cleaned;
        if (!cleaned.startsWith("+")) return "+" + cleaned;
        return cleaned;
    }

    private String hashOtp(String otp) throws NoSuchAlgorithmException {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hash = digest.digest(otp.getBytes(StandardCharsets.UTF_8));
        return HexFormat.of().formatHex(hash);
    }

    private boolean checkRateLimit(String phone) {
        Instant oneHourAgo = Instant.now().minusSeconds(3600);
        long count = otpRepo.countByPhoneAndVerifiedFalseAndCreatedAtAfter(
            normalizePhone(phone), oneHourAgo);
        return count < MAX_REQUESTS_PER_HOUR;
    }

    @PostMapping("/otp/request")
    public ResponseEntity<Map<String, String>> requestOTP(
            @RequestBody Map<String, String> body) throws Exception {
        String phone = body.get("phone");
        if (phone == null || phone.isBlank())
            return ResponseEntity.badRequest()
                .body(Map.of("error", "Phone number is required"));

        if (!checkRateLimit(phone))
            return ResponseEntity.status(429)
                .body(Map.of("error", "Too many requests. Please try again later."));

        String normalizedPhone = normalizePhone(phone);
        String otp = generateOTP();
        String otpHash = hashOtp(otp);

        otpRepo.save(OtpAttempt.builder()
            .phone(normalizedPhone).otpHash(otpHash)
            .expiresAt(Instant.now().plusSeconds(OTP_EXPIRY_MINUTES * 60))
            .failedAttempts(0).verified(false).build());

        Twilio.init(twilioSid, twilioToken);
        Message.creator(new PhoneNumber(normalizedPhone),
            new PhoneNumber(twilioPhone),
            "Your sign-in code is: " + otp + "\nDo not share this code.").create();

        String maskedPhone = normalizedPhone.substring(0, normalizedPhone.length() - 4) + "****";
        return ResponseEntity.ok(Map.of("message", "Code sent", "phone", maskedPhone));
    }

    @PostMapping("/otp/verify")
    public ResponseEntity<Map<String, Object>> verifyOTP(
            @RequestBody Map<String, String> body,
            jakarta.servlet.http.HttpServletResponse response) throws Exception {
        String phone = body.get("phone");
        String otp = body.get("otp");
        if (phone == null || otp == null)
            return ResponseEntity.badRequest()
                .body(Map.of("error", "Phone and code are required"));

        String normalizedPhone = normalizePhone(phone);
        String otpHash = hashOtp(otp);

        OtpAttempt record = otpRepo.findLatestValid(normalizedPhone, Instant.now())
            .orElse(null);
        if (record == null)
            return ResponseEntity.status(401)
                .body(Map.of("error", "Code not found or expired."));

        if (record.getFailedAttempts() >= MAX_ATTEMPTS)
            return ResponseEntity.status(429)
                .body(Map.of("error", "Too many failed attempts."));

        boolean isValid = MessageDigest.isEqual(
            otpHash.getBytes(), record.getOtpHash().getBytes());
        if (!isValid) {
            record.setFailedAttempts(record.getFailedAttempts() + 1);
            otpRepo.save(record);
            return ResponseEntity.status(401).body(Map.of("error", "Invalid code."));
        }

        record.setVerified(true);
        otpRepo.save(record);

        User user = userRepo.findByPhone(normalizedPhone)
            .orElseGet(() -> userRepo.save(User.builder().phone(normalizedPhone).build()));

        SecureRandom sr = new SecureRandom();
        byte[] tokenBytes = new byte[32];
        sr.nextBytes(tokenBytes);
        String sessionToken = HexFormat.of().formatHex(tokenBytes);
        sessionRepo.save(Session.builder().token(sessionToken).userId(user.getId())
            .expiresAt(Instant.now().plus(30, ChronoUnit.DAYS)).build());

        ResponseCookie cookie = ResponseCookie.from("sessionToken", sessionToken)
            .httpOnly(true).secure(true).sameSite("Lax")
            .maxAge(Duration.ofDays(30)).path("/").build();
        response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

        return ResponseEntity.ok(Map.of("message", "Authenticated successfully",
            "user", Map.of("id", user.getId(), "phone", user.getPhone())));
    }
}
import hashlib
import secrets
import re
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel
from twilio.rest import Client as TwilioClient
from sqlalchemy.orm import Session

router = APIRouter()

OTP_LENGTH = 6
OTP_EXPIRY_MINUTES = 10
MAX_ATTEMPTS = 3
MAX_REQUESTS_PER_HOUR = 3

twilio_client = TwilioClient(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)


def generate_otp() -> str:
    max_value = 10 ** OTP_LENGTH
    return str(secrets.randbelow(max_value)).zfill(OTP_LENGTH)


def normalize_phone(phone: str) -> str:
    cleaned = re.sub(r"\D", "", phone)
    if len(cleaned) == 10:
        return f"+1{cleaned}"
    if not cleaned.startswith("+"):
        return f"+{cleaned}"
    return cleaned


def hash_otp(otp: str) -> str:
    return hashlib.sha256(otp.encode()).hexdigest()


def check_rate_limit(db: Session, phone: str) -> bool:
    one_hour_ago = datetime.now(timezone.utc) - timedelta(hours=1)
    count = (db.query(OtpAttempt)
        .filter(OtpAttempt.phone == normalize_phone(phone),
                OtpAttempt.verified == False,
                OtpAttempt.created_at >= one_hour_ago)
        .count())
    return count < MAX_REQUESTS_PER_HOUR


class OtpRequest(BaseModel):
    phone: str

class OtpVerify(BaseModel):
    phone: str
    otp: str


@router.post("/auth/otp/request")
async def request_otp(body: OtpRequest, db: Session = Depends(get_db)):
    if not check_rate_limit(db, body.phone):
        raise HTTPException(status_code=429,
            detail="Too many requests. Please try again later.")

    normalized_phone = normalize_phone(body.phone)
    otp = generate_otp()
    otp_hash = hash_otp(otp)

    db.add(OtpAttempt(
        phone=normalized_phone, otp_hash=otp_hash,
        expires_at=datetime.now(timezone.utc) + timedelta(minutes=OTP_EXPIRY_MINUTES),
        failed_attempts=0, verified=False,
    ))
    db.commit()

    twilio_client.messages.create(
        body=f"Your sign-in code is: {otp}\nDo not share this code.",
        from_=settings.TWILIO_PHONE,
        to=normalized_phone,
    )

    masked_phone = normalized_phone[:-4] + "****"
    return {"message": "Code sent to your phone", "phone": masked_phone}


@router.post("/auth/otp/verify")
async def verify_otp(
    body: OtpVerify,
    response: Response,
    db: Session = Depends(get_db),
):
    normalized_phone = normalize_phone(body.phone)
    otp_hash = hash_otp(body.otp)

    record = (db.query(OtpAttempt)
        .filter(OtpAttempt.phone == normalized_phone,
                OtpAttempt.verified == False,
                OtpAttempt.expires_at > datetime.now(timezone.utc))
        .order_by(OtpAttempt.created_at.desc())
        .first())

    if not record:
        raise HTTPException(status_code=401,
            detail="Code not found or expired. Please request a new one.")

    if record.failed_attempts >= MAX_ATTEMPTS:
        raise HTTPException(status_code=429,
            detail="Too many failed attempts. Please request a new code.")

    is_valid = secrets.compare_digest(otp_hash, record.otp_hash)
    if not is_valid:
        record.failed_attempts += 1
        db.commit()
        raise HTTPException(status_code=401, detail="Invalid code. Please try again.")

    record.verified = True
    db.commit()

    user = db.query(User).filter_by(phone=normalized_phone).first()
    if not user:
        user = User(phone=normalized_phone)
        db.add(user)
        db.commit()

    session_token = secrets.token_hex(32)
    db.add(Session(
        token=session_token, user_id=user.id,
        expires_at=datetime.now(timezone.utc) + timedelta(days=30),
    ))
    db.commit()

    response.set_cookie("sessionToken", session_token,
        httponly=True, secure=True, samesite="lax",
        max_age=30 * 24 * 60 * 60, path="/")

    return {"message": "Authenticated successfully",
            "user": {"id": user.id, "phone": user.phone}}
using System.Security.Cryptography;
using Twilio;
using Twilio.Rest.Api.V2010.Account;
using Twilio.Types;

[ApiController]
[Route("auth")]
public class OtpController : ControllerBase
{
    private const int OtpLength = 6;
    private const int OtpExpiryMinutes = 10;
    private const int MaxAttempts = 3;
    private const int MaxRequestsPerHour = 3;

    private readonly AppDbContext _db;
    private readonly IConfiguration _config;

    public OtpController(AppDbContext db, IConfiguration config)
    { _db = db; _config = config; }

    private static string GenerateOtp()
    {
        int max = (int)Math.Pow(10, OtpLength);
        return RandomNumberGenerator.GetInt32(max).ToString().PadLeft(OtpLength, '0');
    }

    private static string NormalizePhone(string phone)
    {
        var cleaned = new string(phone.Where(char.IsDigit).ToArray());
        return cleaned.Length == 10 ? $"+1{cleaned}" : $"+{cleaned}";
    }

    private static string HashOtp(string otp) =>
        Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(otp))).ToLower();

    private async Task<bool> CheckRateLimitAsync(string phone)
    {
        var oneHourAgo = DateTimeOffset.UtcNow.AddHours(-1);
        var count = await _db.OtpAttempts.CountAsync(a =>
            a.Phone == NormalizePhone(phone) &&
            !a.Verified && a.CreatedAt >= oneHourAgo);
        return count < MaxRequestsPerHour;
    }

    [HttpPost("otp/request")]
    public async Task<IActionResult> RequestOtp([FromBody] OtpRequest body)
    {
        if (!await CheckRateLimitAsync(body.Phone))
            return StatusCode(429, new { error = "Too many requests. Please try again later." });

        var normalizedPhone = NormalizePhone(body.Phone);
        var otp = GenerateOtp();
        var otpHash = HashOtp(otp);

        _db.OtpAttempts.Add(new OtpAttempt {
            Phone = normalizedPhone, OtpHash = otpHash,
            ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(OtpExpiryMinutes),
            FailedAttempts = 0, Verified = false,
        });
        await _db.SaveChangesAsync();

        TwilioClient.Init(_config["Twilio:AccountSid"], _config["Twilio:AuthToken"]);
        await MessageResource.CreateAsync(
            body: $"Your sign-in code is: {otp}\nDo not share this code.",
            from: new PhoneNumber(_config["Twilio:Phone"]),
            to: new PhoneNumber(normalizedPhone));

        var maskedPhone = normalizedPhone[..^4] + "****";
        return Ok(new { message = "Code sent to your phone", phone = maskedPhone });
    }

    [HttpPost("otp/verify")]
    public async Task<IActionResult> VerifyOtp(
        [FromBody] OtpVerifyRequest body)
    {
        var normalizedPhone = NormalizePhone(body.Phone);
        var otpHash = HashOtp(body.Otp);

        var record = await _db.OtpAttempts
            .Where(a => a.Phone == normalizedPhone && !a.Verified &&
                        a.ExpiresAt > DateTimeOffset.UtcNow)
            .OrderByDescending(a => a.CreatedAt)
            .FirstOrDefaultAsync();

        if (record == null)
            return Unauthorized(new { error = "Code not found or expired." });

        if (record.FailedAttempts >= MaxAttempts)
            return StatusCode(429, new { error = "Too many failed attempts." });

        var isValid = CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(otpHash),
            Encoding.UTF8.GetBytes(record.OtpHash));

        if (!isValid)
        {
            record.FailedAttempts++;
            await _db.SaveChangesAsync();
            return Unauthorized(new { error = "Invalid code. Please try again." });
        }

        record.Verified = true;
        await _db.SaveChangesAsync();

        var user = await _db.Users.FirstOrDefaultAsync(u => u.Phone == normalizedPhone)
            ?? _db.Users.Add(new User { Phone = normalizedPhone }).Entity;
        await _db.SaveChangesAsync();

        var sessionToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLower();
        _db.Sessions.Add(new Session {
            Token = sessionToken, UserId = user.Id,
            ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
        });
        await _db.SaveChangesAsync();

        Response.Cookies.Append("sessionToken", sessionToken, new CookieOptions {
            HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax,
            MaxAge = TimeSpan.FromDays(30), Path = "/",
        });

        return Ok(new { message = "Authenticated successfully",
            user = new { user.Id, user.Phone } });
    }
}

Database Schema Addition

model OtpAttempt {
  id              String   @id @default(cuid())
  phone           String
  otpHash         String
  verified        Boolean  @default(false)
  failedAttempts  Int      @default(0)
  expiresAt       DateTime
  createdAt       DateTime @default(now())

  @@index([phone, verified, expiresAt])
}

WebAuthn/FIDO2: มาตรฐานที่ดีที่สุด

WebAuthn (Web Authentication) เป็นมาตรฐาน W3C ที่ใช้ประโยชน์จาก cryptographic keys ที่เก็บไว้ในอุปกรณ์ของคุณ (passkeys, security keys, biometric authentication) เป็นแนวทางการรับรองตัวตนแบบ passwordless ที่ปลอดภัยที่สุด

WebAuthn ทำงานอย่างไร

  1. Registration: ผู้ใช้ให้ข้อมูลประจำตัวและข้อมูลประจำตัว (biometric หรือ security key)
  2. Challenge: เซิร์ฟเวอร์สร้าง challenge
  3. Attestation: อุปกรณ์ลงนาม challenge ด้วย private key และส่งกลับ public key
  4. Storage: เซิร์ฟเวอร์เก็บ public key ที่เกี่ยวข้องกับผู้ใช้
  5. Authentication: สำหรับการเข้าสู่ระบบ เซิร์ฟเวอร์ส่ง challenge ใหม่
  6. Assertion: อุปกรณ์ลงนามด้วย private key
  7. Verification: เซิร์ฟเวอร์ตรวจสอบลายเซ็นโดยใช้ stored public key

Production Implementation with @simplewebauthn/server

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
  RegistrationResponseJSON,
  AuthenticationResponseJSON,
} from '@simplewebauthn/server';
import {
  isoBase64URL,
  isoUint8Array,
} from '@simplewebauthn/server/helpers/iso';
import { Router, Request, Response } from 'express';
import crypto from 'crypto';
import { prisma } from '@/lib/prisma';
import { logger } from '@/lib/logger';

const RP_ID = process.env.RP_ID || 'localhost';
const RP_NAME = process.env.RP_NAME || 'My Application';
const ORIGIN = process.env.ORIGIN || 'http://localhost:3000';

/**
 * Start WebAuthn registration
 * Returns options for the authenticator
 */
export async function startWebAuthnRegistration(
  req: Request,
  res: Response
) {
  const { email } = req.body;

  if (!email) {
    return res.status(400).json({ error: 'Email is required' });
  }

  try {
    // Check if user exists
    let user = await prisma.user.findUnique({
      where: { email },
      include: { webAuthnCredentials: true },
    });

    if (!user) {
      user = await prisma.user.create({
        data: {
          email,
          name: email.split('@')[0],
        },
      });
    }

    // Generate registration options
    const options = await generateRegistrationOptions({
      rpID: RP_ID,
      rpName: RP_NAME,
      userID: isoUint8Array.fromUTF8String(user.id),
      userName: email,
      userDisplayName: user.name || email,
      attestationType: 'direct',
      authenticatorSelection: {
        authenticatorAttachment: 'platform',
        residentKey: 'preferred',
        userVerification: 'preferred',
      },
      supportedAlgorithmIDs: [-7, -257], // ES256, RS256
    });

    // Store challenge in database for later verification
    await prisma.webAuthnChallenge.create({
      data: {
        challenge: options.challenge,
        userId: user.id,
        type: 'registration',
        expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes
      },
    });

    return res.status(200).json(options);
  } catch (error) {
    logger.error('Error starting WebAuthn registration', { error });
    return res.status(500).json({
      error: 'Failed to start registration',
    });
  }
}

/**
 * Complete WebAuthn registration
 * Verify the attestation and store the credential
 */
export async function completeWebAuthnRegistration(
  req: Request,
  res: Response
) {
  const { email, credential } = req.body;

  if (!email || !credential) {
    return res.status(400).json({
      error: 'Email and credential are required',
    });
  }

  try {
    const user = await prisma.user.findUnique({
      where: { email },
    });

    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }

    // Find the challenge
    const challenge = await prisma.webAuthnChallenge.findFirst({
      where: {
        userId: user.id,
        type: 'registration',
        expiresAt: { gt: new Date() },
      },
      orderBy: { createdAt: 'desc' },
    });

    if (!challenge) {
      return res.status(400).json({
        error: 'Challenge expired. Please start registration again.',
      });
    }

    // Verify the registration response
    let verification;
    try {
      verification = await verifyRegistrationResponse({
        response: credential as RegistrationResponseJSON,
        expectedChallenge: challenge.challenge,
        expectedOrigin: ORIGIN,
        expectedRPID: RP_ID,
      });
    } catch (error) {
      logger.warn('WebAuthn registration verification failed', {
        email,
        error,
      });
      return res.status(400).json({
        error: 'Registration verification failed',
      });
    }

    if (!verification.verified || !verification.registrationInfo) {
      return res.status(400).json({
        error: 'Registration could not be verified',
      });
    }

    const { registrationInfo } = verification;

    // Store the credential
    await prisma.webAuthnCredential.create({
      data: {
        userId: user.id,
        credentialID: isoBase64URL.fromBuffer(registrationInfo.credentialID),
        publicKeyBytes: isoBase64URL.fromBuffer(registrationInfo.credentialPublicKey),
        signCount: registrationInfo.signCount,
        transports: credential.response.transports || [],
        name: credential.response.clientExtensionResults?.credProps?.rk
          ? 'Platform Authenticator'
          : 'Security Key',
      },
    });

    // Mark challenge as used
    await prisma.webAuthnChallenge.update({
      where: { id: challenge.id },
      data: { used: true },
    });

    // Create session
    const sessionToken = crypto.randomBytes(32).toString('hex');
    await prisma.session.create({
      data: {
        token: sessionToken,
        userId: user.id,
        expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      },
    });

    res.cookie('sessionToken', sessionToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 30 * 24 * 60 * 60 * 1000,
      path: '/',
    });

    logger.info('WebAuthn credential registered', {
      userId: user.id,
      credentialID: registrationInfo.credentialID.byteLength,
    });

    return res.status(200).json({
      message: 'Passkey registered successfully',
      user: { id: user.id, email: user.email },
    });
  } catch (error) {
    logger.error('Error completing WebAuthn registration', { error });
    return res.status(500).json({
      error: 'Failed to complete registration',
    });
  }
}

/**
 * Start WebAuthn authentication
 */
export async function startWebAuthnAuthentication(
  req: Request,
  res: Response
) {
  const { email } = req.body;

  if (!email) {
    return res.status(400).json({ error: 'Email is required' });
  }

  try {
    const user = await prisma.user.findUnique({
      where: { email },
      include: { webAuthnCredentials: true },
    });

    if (!user || user.webAuthnCredentials.length === 0) {
      return res.status(404).json({
        error: 'User has no registered passkeys',
      });
    }

    // Generate authentication options
    const options = await generateAuthenticationOptions({
      rpID: RP_ID,
      allowCredentials: user.webAuthnCredentials.map((cred) => ({
        id: isoBase64URL.toBuffer(cred.credentialID),
        type: 'public-key',
        transports: cred.transports as AuthenticatorTransport[],
      })),
      userVerification: 'preferred',
    });

    // Store challenge
    await prisma.webAuthnChallenge.create({
      data: {
        challenge: options.challenge,
        userId: user.id,
        type: 'authentication',
        expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes
      },
    });

    return res.status(200).json(options);
  } catch (error) {
    logger.error('Error starting WebAuthn authentication', { error });
    return res.status(500).json({
      error: 'Failed to start authentication',
    });
  }
}

/**
 * Complete WebAuthn authentication
 */
export async function completeWebAuthnAuthentication(
  req: Request,
  res: Response
) {
  const { email, assertion } = req.body;

  if (!email || !assertion) {
    return res.status(400).json({
      error: 'Email and assertion are required',
    });
  }

  try {
    const user = await prisma.user.findUnique({
      where: { email },
      include: { webAuthnCredentials: true },
    });

    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }

    // Find the challenge
    const challenge = await prisma.webAuthnChallenge.findFirst({
      where: {
        userId: user.id,
        type: 'authentication',
        expiresAt: { gt: new Date() },
      },
      orderBy: { createdAt: 'desc' },
    });

    if (!challenge) {
      return res.status(400).json({
        error: 'Challenge expired. Please start authentication again.',
      });
    }

    // Find the credential that was used
    const credentialID = assertion.id;
    const credential = user.webAuthnCredentials.find(
      (cred) => cred.credentialID === credentialID
    );

    if (!credential) {
      return res.status(404).json({
        error: 'Credential not found',
      });
    }

    // Verify the authentication response
    let verification;
    try {
      verification = await verifyAuthenticationResponse({
        response: assertion as AuthenticationResponseJSON,
        expectedChallenge: challenge.challenge,
        expectedOrigin: ORIGIN,
        expectedRPID: RP_ID,
        credential: {
          id: isoBase64URL.toBuffer(credential.credentialID),
          publicKey: isoBase64URL.toBuffer(credential.publicKeyBytes),
          signCount: credential.signCount,
          transports: credential.transports as AuthenticatorTransport[],
        },
      });
    } catch (error) {
      logger.warn('WebAuthn authentication verification failed', {
        email,
        error,
      });
      return res.status(401).json({
        error: 'Authentication verification failed',
      });
    }

    if (!verification.verified) {
      return res.status(401).json({
        error: 'Authentication could not be verified',
      });
    }

    // Check for cloned authenticator (sign count should increase)
    if (verification.authenticationInfo.signCount <= credential.signCount) {
      logger.warn('Potential cloned authenticator detected', {
        userId: user.id,
        credentialID,
      });
      // You might want to require re-authentication here
    }

    // Update sign count
    await prisma.webAuthnCredential.update({
      where: { id: credential.id },
      data: { signCount: verification.authenticationInfo.signCount },
    });

    // Mark challenge as used
    await prisma.webAuthnChallenge.update({
      where: { id: challenge.id },
      data: { used: true },
    });

    // Create session
    const sessionToken = crypto.randomBytes(32).toString('hex');
    await prisma.session.create({
      data: {
        token: sessionToken,
        userId: user.id,
        expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      },
    });

    res.cookie('sessionToken', sessionToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 30 * 24 * 60 * 60 * 1000,
      path: '/',
    });

    logger.info('User authenticated via WebAuthn', {
      userId: user.id,
      credentialID,
    });

    return res.status(200).json({
      message: 'Authenticated successfully',
      user: { id: user.id, email: user.email },
    });
  } catch (error) {
    logger.error('Error completing WebAuthn authentication', { error });
    return res.status(500).json({
      error: 'Failed to complete authentication',
    });
  }
}
// Spring Boot with webauthn4j
import com.webauthn4j.WebAuthnManager;
import com.webauthn4j.data.*;
import org.springframework.web.bind.annotation.*;
import java.security.SecureRandom;
import java.util.*;

@RestController
@RequestMapping("/auth/webauthn")
public class WebAuthnController {

    private static final String RP_ID = System.getenv("RP_ID");
    private static final String RP_NAME = System.getenv("RP_NAME");
    private static final String ORIGIN = System.getenv("ORIGIN");

    @Autowired private UserRepository userRepo;
    @Autowired private WebAuthnCredentialRepository credRepo;
    @Autowired private WebAuthnChallengeRepository challengeRepo;
    @Autowired private SessionRepository sessionRepo;

    @PostMapping("/registration/start")
    public ResponseEntity<Map<String, Object>> startRegistration(
            @RequestBody Map<String, String> body) {
        String email = body.get("email");
        if (email == null) return ResponseEntity.badRequest()
            .body(Map.of("error", "Email is required"));

        User user = userRepo.findByEmail(email).orElseGet(() ->
            userRepo.save(User.builder().email(email)
                .name(email.split("@")[0]).build()));

        byte[] challengeBytes = new byte[32];
        new SecureRandom().nextBytes(challengeBytes);
        String challenge = Base64.getUrlEncoder().withoutPadding()
            .encodeToString(challengeBytes);

        challengeRepo.save(WebAuthnChallenge.builder()
            .challenge(challenge).userId(user.getId())
            .type("registration")
            .expiresAt(Instant.now().plusSeconds(900))
            .build());

        Map<String, Object> options = new LinkedHashMap<>();
        options.put("challenge", challenge);
        options.put("rp", Map.of("id", RP_ID, "name", RP_NAME));
        options.put("user", Map.of("id",
            Base64.getUrlEncoder().withoutPadding()
                .encodeToString(user.getId().getBytes()),
            "name", email, "displayName", user.getName()));
        options.put("pubKeyCredParams", List.of(
            Map.of("type", "public-key", "alg", -7),
            Map.of("type", "public-key", "alg", -257)));
        options.put("authenticatorSelection", Map.of(
            "authenticatorAttachment", "platform",
            "residentKey", "preferred",
            "userVerification", "preferred"));
        return ResponseEntity.ok(options);
    }

    @PostMapping("/registration/complete")
    public ResponseEntity<Map<String, Object>> completeRegistration(
            @RequestBody Map<String, Object> body,
            jakarta.servlet.http.HttpServletResponse response) {
        // Verify with webauthn4j, store credential, create session
        return ResponseEntity.ok(Map.of("message", "Passkey registered successfully"));
    }

    @PostMapping("/authentication/start")
    public ResponseEntity<Map<String, Object>> startAuthentication(
            @RequestBody Map<String, String> body) {
        String email = body.get("email");
        User user = userRepo.findByEmail(email)
            .orElseThrow(() -> new ResponseStatusException(
                HttpStatus.NOT_FOUND, "User not found"));

        List<WebAuthnCredential> creds = credRepo.findByUserId(user.getId());
        if (creds.isEmpty()) return ResponseEntity.status(404)
            .body(Map.of("error", "User has no registered passkeys"));

        byte[] challengeBytes = new byte[32];
        new SecureRandom().nextBytes(challengeBytes);
        String challenge = Base64.getUrlEncoder().withoutPadding()
            .encodeToString(challengeBytes);

        challengeRepo.save(WebAuthnChallenge.builder()
            .challenge(challenge).userId(user.getId())
            .type("authentication")
            .expiresAt(Instant.now().plusSeconds(600))
            .build());

        List<Map<String, Object>> allowCredentials = creds.stream()
            .map(c -> Map.<String, Object>of(
                "id", c.getCredentialId(),
                "type", "public-key",
                "transports", c.getTransports()))
            .toList();

        return ResponseEntity.ok(Map.of(
            "challenge", challenge,
            "rpId", RP_ID,
            "allowCredentials", allowCredentials,
            "userVerification", "preferred"));
    }

    @PostMapping("/authentication/complete")
    public ResponseEntity<Map<String, Object>> completeAuthentication(
            @RequestBody Map<String, Object> body,
            jakarta.servlet.http.HttpServletResponse response) {
        // Verify assertion, update sign count, create session
        return ResponseEntity.ok(Map.of("message", "Authenticated successfully"));
    }
}
from fastapi import APIRouter, Depends, HTTPException, Response
from py_webauthn import (
    generate_registration_options, verify_registration_response,
    generate_authentication_options, verify_authentication_response,
    options_to_json,
)
from py_webauthn.helpers.structs import (
    AuthenticatorSelectionCriteria, ResidentKeyRequirement,
    UserVerificationRequirement, AttestationConveyancePreference,
    AuthenticatorAttachment,
)
import secrets, base64
from sqlalchemy.orm import Session

router = APIRouter()

RP_ID = settings.RP_ID or "localhost"
RP_NAME = settings.RP_NAME or "My Application"
ORIGIN = settings.ORIGIN or "http://localhost:3000"


@router.post("/auth/webauthn/registration/start")
async def start_registration(body: dict, db: Session = Depends(get_db)):
    email = body.get("email")
    if not email:
        raise HTTPException(status_code=400, detail="Email is required")

    user = db.query(User).filter_by(email=email).first()
    if not user:
        user = User(email=email, name=email.split("@")[0])
        db.add(user)
        db.commit()

    options = generate_registration_options(
        rp_id=RP_ID, rp_name=RP_NAME,
        user_id=user.id.encode(), user_name=email,
        user_display_name=user.name or email,
        attestation=AttestationConveyancePreference.DIRECT,
        authenticator_selection=AuthenticatorSelectionCriteria(
            authenticator_attachment=AuthenticatorAttachment.PLATFORM,
            resident_key=ResidentKeyRequirement.PREFERRED,
            user_verification=UserVerificationRequirement.PREFERRED,
        ),
    )

    db.add(WebAuthnChallenge(
        challenge=options.challenge, user_id=user.id, type="registration",
        expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
    ))
    db.commit()
    return options_to_json(options)


@router.post("/auth/webauthn/registration/complete")
async def complete_registration(
    body: dict, response: Response, db: Session = Depends(get_db)
):
    email = body.get("email")
    credential = body.get("credential")
    user = db.query(User).filter_by(email=email).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")

    challenge_record = (db.query(WebAuthnChallenge)
        .filter_by(user_id=user.id, type="registration")
        .filter(WebAuthnChallenge.expires_at > datetime.now(timezone.utc))
        .order_by(WebAuthnChallenge.created_at.desc()).first())
    if not challenge_record:
        raise HTTPException(status_code=400,
            detail="Challenge expired. Please start registration again.")

    verification = verify_registration_response(
        credential=credential,
        expected_challenge=challenge_record.challenge,
        expected_rp_id=RP_ID, expected_origin=ORIGIN,
    )

    db.add(WebAuthnCredential(
        user_id=user.id,
        credential_id=base64.urlsafe_b64encode(
            verification.credential_id).rstrip(b'=').decode(),
        public_key_bytes=base64.urlsafe_b64encode(
            verification.credential_public_key).rstrip(b'=').decode(),
        sign_count=verification.sign_count,
        transports=credential.get("response", {}).get("transports", []),
        name="Platform Authenticator",
    ))
    challenge_record.used = True
    db.commit()

    session_token = secrets.token_hex(32)
    db.add(Session(token=session_token, user_id=user.id,
        expires_at=datetime.now(timezone.utc) + timedelta(days=30)))
    db.commit()

    response.set_cookie("sessionToken", session_token,
        httponly=True, secure=True, samesite="lax",
        max_age=30 * 24 * 60 * 60, path="/")
    return {"message": "Passkey registered successfully",
            "user": {"id": user.id, "email": user.email}}


@router.post("/auth/webauthn/authentication/start")
async def start_authentication(body: dict, db: Session = Depends(get_db)):
    email = body.get("email")
    user = db.query(User).filter_by(email=email).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")

    creds = db.query(WebAuthnCredential).filter_by(user_id=user.id).all()
    if not creds:
        raise HTTPException(status_code=404,
            detail="User has no registered passkeys")

    options = generate_authentication_options(
        rp_id=RP_ID,
        allow_credentials=[{
            "id": base64.urlsafe_b64decode(c.credential_id + "=="),
            "type": "public-key", "transports": c.transports,
        } for c in creds],
        user_verification=UserVerificationRequirement.PREFERRED,
    )

    db.add(WebAuthnChallenge(challenge=options.challenge,
        user_id=user.id, type="authentication",
        expires_at=datetime.now(timezone.utc) + timedelta(minutes=10)))
    db.commit()
    return options_to_json(options)


@router.post("/auth/webauthn/authentication/complete")
async def complete_authentication(
    body: dict, response: Response, db: Session = Depends(get_db)
):
    email = body.get("email")
    assertion = body.get("assertion")
    user = db.query(User).filter_by(email=email).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")

    challenge_record = (db.query(WebAuthnChallenge)
        .filter_by(user_id=user.id, type="authentication")
        .filter(WebAuthnChallenge.expires_at > datetime.now(timezone.utc))
        .order_by(WebAuthnChallenge.created_at.desc()).first())
    if not challenge_record:
        raise HTTPException(status_code=400,
            detail="Challenge expired. Please start authentication again.")

    credential_id = assertion.get("id")
    cred = next((c for c in user.webauthn_credentials
        if c.credential_id == credential_id), None)
    if not cred:
        raise HTTPException(status_code=404, detail="Credential not found")

    verification = verify_authentication_response(
        credential=assertion,
        expected_challenge=challenge_record.challenge,
        expected_rp_id=RP_ID, expected_origin=ORIGIN,
        credential_public_key=base64.urlsafe_b64decode(cred.public_key_bytes + "=="),
        credential_current_sign_count=cred.sign_count,
    )

    if verification.new_sign_count <= cred.sign_count:
        logger.warning(f"Potential cloned authenticator: user={user.id}")

    cred.sign_count = verification.new_sign_count
    challenge_record.used = True
    db.commit()

    session_token = secrets.token_hex(32)
    db.add(Session(token=session_token, user_id=user.id,
        expires_at=datetime.now(timezone.utc) + timedelta(days=30)))
    db.commit()

    response.set_cookie("sessionToken", session_token,
        httponly=True, secure=True, samesite="lax",
        max_age=30 * 24 * 60 * 60, path="/")
    return {"message": "Authenticated successfully",
            "user": {"id": user.id, "email": user.email}}
// ASP.NET Core with Fido2NetLib
using Fido2NetLib;
using Fido2NetLib.Objects;
using System.Security.Cryptography;

[ApiController]
[Route("auth/webauthn")]
public class WebAuthnController : ControllerBase
{
    private readonly IFido2 _fido2;
    private readonly AppDbContext _db;

    public WebAuthnController(IFido2 fido2, AppDbContext db)
    { _fido2 = fido2; _db = db; }

    [HttpPost("registration/start")]
    public async Task<IActionResult> StartRegistration([FromBody] EmailRequest body)
    {
        var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == body.Email)
            ?? _db.Users.Add(new User {
                Email = body.Email, Name = body.Email.Split('@')[0]
            }).Entity;
        await _db.SaveChangesAsync();

        var existingCreds = await _db.WebAuthnCredentials
            .Where(c => c.UserId == user.Id)
            .Select(c => new PublicKeyCredentialDescriptor(
                Convert.FromBase64String(c.CredentialId)))
            .ToListAsync();

        var fido2User = new Fido2User {
            Id = System.Text.Encoding.UTF8.GetBytes(user.Id),
            Name = body.Email, DisplayName = user.Name ?? body.Email,
        };

        var options = _fido2.RequestNewCredential(fido2User, existingCreds,
            new AuthenticatorSelection {
                AuthenticatorAttachment = AuthenticatorAttachment.Platform,
                ResidentKey = ResidentKeyRequirement.Preferred,
                UserVerification = UserVerificationRequirement.Preferred,
            }, AttestationConveyancePreference.Direct);

        _db.WebAuthnChallenges.Add(new WebAuthnChallenge {
            Challenge = Convert.ToBase64String(options.Challenge),
            UserId = user.Id, Type = "registration",
            ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(15),
        });
        await _db.SaveChangesAsync();
        return Ok(options);
    }

    [HttpPost("registration/complete")]
    public async Task<IActionResult> CompleteRegistration(
        [FromBody] RegistrationCompleteRequest body)
    {
        // Verify with Fido2NetLib, store credential, create session
        var sessionToken = CreateSession(body.UserId);
        SetSessionCookie(sessionToken);
        return Ok(new { message = "Passkey registered successfully" });
    }

    [HttpPost("authentication/start")]
    public async Task<IActionResult> StartAuthentication([FromBody] EmailRequest body)
    {
        var user = await _db.Users.Include(u => u.WebAuthnCredentials)
            .FirstOrDefaultAsync(u => u.Email == body.Email);
        if (user == null || !user.WebAuthnCredentials.Any())
            return NotFound(new { error = "User has no registered passkeys" });

        var allowedCreds = user.WebAuthnCredentials.Select(c =>
            new PublicKeyCredentialDescriptor(
                Convert.FromBase64String(c.CredentialId))).ToList();

        var options = _fido2.GetAssertionOptions(allowedCreds,
            UserVerificationRequirement.Preferred);

        _db.WebAuthnChallenges.Add(new WebAuthnChallenge {
            Challenge = Convert.ToBase64String(options.Challenge),
            UserId = user.Id, Type = "authentication",
            ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10),
        });
        await _db.SaveChangesAsync();
        return Ok(options);
    }

    [HttpPost("authentication/complete")]
    public async Task<IActionResult> CompleteAuthentication(
        [FromBody] AuthenticationCompleteRequest body)
    {
        // Verify assertion, update sign count, create session
        var sessionToken = CreateSession(body.UserId);
        SetSessionCookie(sessionToken);
        return Ok(new { message = "Authenticated successfully" });
    }

    private string CreateSession(string userId)
    {
        var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLower();
        _db.Sessions.Add(new Session {
            Token = token, UserId = userId,
            ExpiresAt = DateTimeOffset.UtcNow.AddDays(30) });
        _db.SaveChanges();
        return token;
    }

    private void SetSessionCookie(string token) =>
        Response.Cookies.Append("sessionToken", token, new CookieOptions {
            HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax,
            MaxAge = TimeSpan.FromDays(30), Path = "/",
        });
}

Database Schema for WebAuthn

model WebAuthnCredential {
  id              String   @id @default(cuid())
  userId          String
  user            User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  credentialID    String   @unique
  publicKeyBytes  String   // Base64URL encoded
  signCount       Int
  transports      String[] // JSON array of transports
  name            String   // User-friendly name
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  @@index([userId])
}

model WebAuthnChallenge {
  id        String   @id @default(cuid())
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  challenge String
  type      String   // 'registration' or 'authentication'
  used      Boolean  @default(false)
  expiresAt DateTime
  createdAt DateTime @default(now())

  @@index([userId, type, expiresAt])
}
AspectMagic LinksSMS OTPWebAuthn
SecurityVery HighHighHighest (phishing-resistant)
Phishing RiskMedium (email can be spoofed)Low (OTP has expiry)None (public key cryptography)
User FrictionVery Low (1 click)Low (copy-paste)Very Low (biometric)
DependencyEmail deliverySMS/TwilioDevice with authenticator
CostFree/SMTP~$0.05 per SMSFree
RecoveryEmail access requiredSIM swap riskDevice recovery required
Mobile-FriendlyExcellentExcellentExcellent
Desktop-FriendlyExcellentGoodExcellent
Best ForGeneral web appsHigh-security + SMSFinancial services, enterprise

วิธีการแบบไฮบริด: Passwordless หลัก, Password Fallback

ระบบโปรดักชั่นส่วนใหญ่ใช้ passwordless เป็นวิธีการรับรองตัวตนหลัก แต่คงไว้การรับรองตัวตนด้วยรหัสผ่านเป็น fallback สำหรับกรณีที่หลุดเหลือ (การสูญหาย email/โทรศัพท์ security key ล็อก)

/**
 * Hybrid authentication endpoint
 * Tries passwordless first, falls back to password
 */
export async function hybridAuth(req: Request, res: Response) {
  const { email, password } = req.body;

  if (!email) {
    return res.status(400).json({ error: 'Email is required' });
  }

  // If password is provided, handle as password auth
  if (password) {
    return handlePasswordAuth(req, res);
  }

  // Otherwise, send magic link
  return requestMagicLink(req, res);
}

async function handlePasswordAuth(req: Request, res: Response) {
  const { email, password } = req.body;

  if (!password) {
    return res.status(400).json({ error: 'Password is required' });
  }

  try {
    const user = await prisma.user.findUnique({
      where: { email },
      include: { password: true },
    });

    if (!user || !user.password) {
      // User hasn't set up password fallback
      return res.status(401).json({
        error: 'User or password not found',
      });
    }

    // Verify password using bcrypt
    const isValid = await bcrypt.compare(password, user.password.hash);
    if (!isValid) {
      return res.status(401).json({
        error: 'Invalid credentials',
      });
    }

    // Create session
    const sessionToken = crypto.randomBytes(32).toString('hex');
    await prisma.session.create({
      data: {
        token: sessionToken,
        userId: user.id,
        expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
        authMethod: 'password',
      },
    });

    res.cookie('sessionToken', sessionToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 30 * 24 * 60 * 60 * 1000,
      path: '/',
    });

    logger.info('User authenticated via password fallback', { userId: user.id });

    return res.status(200).json({
      message: 'Authenticated successfully',
      user: { id: user.id, email: user.email },
    });
  } catch (error) {
    logger.error('Error during password authentication', { error });
    return res.status(500).json({
      error: 'Authentication failed',
    });
  }
}
@RestController
@RequestMapping("/auth")
public class HybridAuthController {

    @Autowired private UserRepository userRepo;
    @Autowired private SessionRepository sessionRepo;
    @Autowired private MagicLinkController magicLinkController;
    private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    @PostMapping("/login")
    public ResponseEntity<Map<String, Object>> hybridAuth(
            @RequestBody Map<String, String> body,
            jakarta.servlet.http.HttpServletResponse response) {
        String email = body.get("email");
        String password = body.get("password");

        if (email == null || email.isBlank())
            return ResponseEntity.badRequest().body(Map.of("error", "Email is required"));

        if (password != null && !password.isBlank())
            return handlePasswordAuth(email, password, response);

        return magicLinkController.requestMagicLink(body);
    }

    private ResponseEntity<Map<String, Object>> handlePasswordAuth(
            String email, String password,
            jakarta.servlet.http.HttpServletResponse response) {
        User user = userRepo.findByEmailWithPassword(email).orElse(null);

        if (user == null || user.getPasswordHash() == null)
            return ResponseEntity.status(401)
                .body(Map.of("error", "User or password not found"));

        if (!passwordEncoder.matches(password, user.getPasswordHash()))
            return ResponseEntity.status(401)
                .body(Map.of("error", "Invalid credentials"));

        SecureRandom sr = new SecureRandom();
        byte[] tokenBytes = new byte[32];
        sr.nextBytes(tokenBytes);
        String sessionToken = HexFormat.of().formatHex(tokenBytes);

        sessionRepo.save(Session.builder()
            .token(sessionToken).userId(user.getId())
            .expiresAt(Instant.now().plus(30, ChronoUnit.DAYS))
            .authMethod("password").build());

        ResponseCookie cookie = ResponseCookie.from("sessionToken", sessionToken)
            .httpOnly(true).secure(true).sameSite("Lax")
            .maxAge(Duration.ofDays(30)).path("/").build();
        response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

        return ResponseEntity.ok(Map.of(
            "message", "Authenticated successfully",
            "user", Map.of("id", user.getId(), "email", user.getEmail())));
    }
}
from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


class LoginRequest(BaseModel):
    email: EmailStr
    password: str | None = None


@router.post("/auth/login")
async def hybrid_auth(
    body: LoginRequest,
    response: Response,
    db: Session = Depends(get_db),
):
    if not body.email:
        raise HTTPException(status_code=400, detail="Email is required")

    if body.password:
        return await handle_password_auth(body.email, body.password, response, db)

    return await request_magic_link(body, db)


async def handle_password_auth(
    email: str, password: str,
    response: Response, db: Session,
) -> dict:
    user = db.query(User).filter_by(email=email).first()

    if not user or not user.password_hash:
        raise HTTPException(status_code=401,
            detail="User or password not found")

    if not pwd_context.verify(password, user.password_hash):
        raise HTTPException(status_code=401, detail="Invalid credentials")

    session_token = secrets.token_hex(32)
    db.add(Session(
        token=session_token, user_id=user.id,
        expires_at=datetime.now(timezone.utc) + timedelta(days=30),
        auth_method="password",
    ))
    db.commit()

    response.set_cookie("sessionToken", session_token,
        httponly=True, secure=True, samesite="lax",
        max_age=30 * 24 * 60 * 60, path="/")

    return {"message": "Authenticated successfully",
            "user": {"id": user.id, "email": user.email}}
[ApiController]
[Route("auth")]
public class HybridAuthController : ControllerBase
{
    private readonly AppDbContext _db;
    private readonly MagicLinkController _magicLink;

    public HybridAuthController(AppDbContext db, MagicLinkController magicLink)
    { _db = db; _magicLink = magicLink; }

    [HttpPost("login")]
    public async Task<IActionResult> HybridAuth([FromBody] LoginRequest body)
    {
        if (string.IsNullOrWhiteSpace(body.Email))
            return BadRequest(new { error = "Email is required" });

        if (!string.IsNullOrWhiteSpace(body.Password))
            return await HandlePasswordAuth(body.Email, body.Password);

        return await _magicLink.RequestMagicLink(
            new MagicLinkRequest { Email = body.Email });
    }

    private async Task<IActionResult> HandlePasswordAuth(string email, string password)
    {
        var user = await _db.Users
            .Include(u => u.PasswordCredential)
            .FirstOrDefaultAsync(u => u.Email == email);

        if (user?.PasswordCredential == null)
            return Unauthorized(new { error = "User or password not found" });

        if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordCredential.Hash))
            return Unauthorized(new { error = "Invalid credentials" });

        var sessionToken = Convert.ToHexString(
            RandomNumberGenerator.GetBytes(32)).ToLower();
        _db.Sessions.Add(new Session {
            Token = sessionToken, UserId = user.Id,
            ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
            AuthMethod = "password",
        });
        await _db.SaveChangesAsync();

        Response.Cookies.Append("sessionToken", sessionToken, new CookieOptions {
            HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax,
            MaxAge = TimeSpan.FromDays(30), Path = "/",
        });

        return Ok(new { message = "Authenticated successfully",
            user = new { user.Id, user.Email } });
    }
}

การพิจารณาด้านความปลอดภัย

Email Deliverability

Magic links พึ่งพาอีเมลถึงกล่องขาเข้าของผู้ใช้ ใช้:

  • SPF, DKIM, DMARC records สำหรับชื่อเสียงของผู้ส่ง
  • ตรวจสอบอัตราการกลับมา
  • ใช้บริการอีเมลสำหรับสิ่งที่เฉพาะเจาะจง (SendGrid, AWS SES)
  • รวมลิงก์ยกเลิกการสมัครสมาชิก (สำหรับการปฏิบัติตามกฎหมาย)

SIM Swapping

SMS OTP มีความเสี่ยงต่อการสลับ SIM ซึ่งผู้โจมตีโดยการล่อลวงสังคมให้ผู้ให้บริการโอนหมายเลขโทรศัพท์ การบรรเทา:

  • ใช้ passwordless เป็นวิธีการหลัก
  • SMS OTP สำหรับการดำเนินการที่ละเอียดอ่อนเท่านั้น
  • ต้องมีการตรวจสอบเพิ่มเติม (คำถามความปลอดภัย รหัสการกู้คืน)
  • ตรวจสอบรูปแบบการรับรองตัวตนที่ผิดปกติ

Token Entropy

Tokens สุ่มทั้งหมดต้องใช้การสร้างความปลอดภัยด้วยการเข้ารหัส:

// Good: cryptographically secure
crypto.randomBytes(32).toString('hex'); // 256 bits of entropy

// Bad: Math.random() is not secure
Math.random().toString(36).substring(7);

// Good: crypto for OTP
crypto.randomInt(0, 999999);
// Good: cryptographically secure
SecureRandom sr = new SecureRandom();
byte[] bytes = new byte[32];
sr.nextBytes(bytes);
String token = HexFormat.of().formatHex(bytes); // 256 bits of entropy

// Bad: Random is not cryptographically secure
// new Random().nextInt()  -- DO NOT USE

// Good: secure OTP
int otp = sr.nextInt(1_000_000); // 0–999999
String otpStr = String.format("%06d", otp);
import secrets, os

# Good: cryptographically secure
token = secrets.token_hex(32)   # 256 bits of entropy
token_bytes = os.urandom(32)    # also fine

# Bad: random is not secure
# import random; random.randint(...)  -- DO NOT USE

# Good: secure OTP
otp = secrets.randbelow(1_000_000)
otp_str = str(otp).zfill(6)
using System.Security.Cryptography;

// Good: cryptographically secure
var bytes = RandomNumberGenerator.GetBytes(32);
var token = Convert.ToHexString(bytes).ToLower(); // 256 bits of entropy

// Bad: System.Random is not cryptographically secure
// new Random().Next()  -- DO NOT USE

// Good: secure OTP
int otp = RandomNumberGenerator.GetInt32(1_000_000); // 0–999999
string otpStr = otp.ToString("D6");

Rate Limiting & Abuse Prevention

ใช้หลายชั้น:

// Per-user rate limiting
const rateLimiter = new RateLimiter({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 5, // max 5 requests per hour
});

// IP-based rate limiting for registration
const registrationLimiter = new RateLimiter({
  windowMs: 60 * 60 * 1000,
  max: 10, // max 10 registrations per hour per IP
  keyGenerator: (req) => req.ip,
});
// Spring Boot + Bucket4j
import io.github.bucket4j.*;
import java.time.Duration;

@Component
public class RateLimiterService {
    private final Map<String, Bucket> userBuckets = new ConcurrentHashMap<>();
    private final Map<String, Bucket> ipBuckets = new ConcurrentHashMap<>();

    // Per-user: max 5 requests per hour
    public Bucket getUserBucket(String email) {
        return userBuckets.computeIfAbsent(email, k ->
            Bucket.builder()
                .addLimit(Bandwidth.classic(5, Refill.greedy(5, Duration.ofHours(1))))
                .build());
    }

    // Per-IP: max 10 registrations per hour
    public Bucket getIpBucket(String ip) {
        return ipBuckets.computeIfAbsent(ip, k ->
            Bucket.builder()
                .addLimit(Bandwidth.classic(10, Refill.greedy(10, Duration.ofHours(1))))
                .build());
    }

    public boolean tryConsume(Bucket bucket) {
        return bucket.tryConsume(1);
    }
}
from slowapi import Limiter
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)

# Per-user rate limiting (5 requests per hour)
@router.post("/auth/magic-link")
@limiter.limit("5/hour", key_func=lambda req: req.state.email)
async def request_magic_link(request: Request, body: MagicLinkRequest):
    ...

# IP-based rate limiting for registration (10 per hour)
@router.post("/auth/register")
@limiter.limit("10/hour")
async def register(request: Request, body: RegisterRequest):
    ...
// ASP.NET Core rate limiting (built-in .NET 7+)
builder.Services.AddRateLimiter(options => {
    // Per-user: 5 requests per hour
    options.AddPolicy("per-user-auth", context => {
        var email = context.Request.Form["email"].ToString();
        return RateLimitPartition.GetFixedWindowLimiter(email, _ =>
            new FixedWindowRateLimiterOptions {
                PermitLimit = 5,
                Window = TimeSpan.FromHours(1),
            });
    });

    // Per-IP: 10 registrations per hour
    options.AddPolicy("per-ip-registration", context => {
        var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
        return RateLimitPartition.GetFixedWindowLimiter(ip, _ =>
            new FixedWindowRateLimiterOptions {
                PermitLimit = 10,
                Window = TimeSpan.FromHours(1),
            });
    });
});

// Apply on controller actions:
// [EnableRateLimiting("per-user-auth")]

Production Checklist

  • ใช้ HTTPS ทุกที่ (TLS 1.3+)
  • ใช้ CSRF protection (SameSite cookies, CSRF tokens)
  • ตั้งค่าแฟล็ก secure cookie (httpOnly, secure, sameSite)
  • ใช้ rate limiting บนปลายทางการรับรองตัวตนทั้งหมด
  • ตรวจสอบรูปแบบการรับรองตัวตนที่น่าสงสัย
  • บันทึกความพยายามการรับรองตัวตนทั้งหมด (โดยไม่มีข้อมูลที่ละเอียดอ่อน)
  • ใช้ exponential backoff สำหรับความพยายามที่ล้มเหลว
  • ใช้หน้าต่างหมดอายุสั้น (15 นาทีสำหรับ magic links, 10 นาทีสำหรับ OTP)
  • Hash tokens ก่อนจัดเก็บในฐานข้อมูล
  • การตรวจสอบความปลอดภัยปกติและการทดสอบเจาะระบบ
  • ตรวจสอบ OWASP Top 10 สำหรับภัยคุกคามที่เกิดขึ้นใหม่
  • ใช้กลไกการกู้คืนบัญชี
  • ทดสอบการเข้ารหัสฐานข้อมูลขณะนิ่ง
  • ตั้งค่าการแจ้งเตือนสำหรับรูปแบบการเข้าถึงที่ผิดปกติ
  • เอกสารนโยบายความปลอดภัยสำหรับทีม
  • อัปเดตการพึ่งพาปกติและแพตช์ความปลอดภัย

สรุป

การรับรองตัวตนแบบ passwordless แสดงถึงการปรับปรุงที่สำคัญเมื่อเปรียบเทียบกับระบบที่ใช้รหัสผ่าน Magic links มีประสบการณ์ผู้ใช้ที่ยอดเยี่ยมโดยมีความปลอดภัยที่ดี SMS OTP ให้ความมั่นใจที่สูงขึ้น และ WebAuthn มอบความปลอดภัยที่แข็งแกร่งที่สุดโดยมี UX สมัยใหม่

วิธีที่ดีที่สุดรวมวิธีการหลายวิธี: ให้ WebAuthn ลำดับความสำคัญสำหรับผู้ใช้ที่มีอุปกรณ์ที่เข้ากันได้ fallback ไปยัง magic links สำหรับผู้ใช้ทั่วไป และคงไว้การรับรองตัวตนด้วยรหัสผ่านเป็น fallback สุดท้ายสำหรับสถานการณ์การกู้คืน

ใช้รูปแบบเหล่านี้โดยคำนึงถึง rate limiting, token expiry และการจัดเก็บที่ปลอดภัย และผู้ใช้จะสนุกกับความปลอดภัยที่ดีกว่าและประสบการณ์การรับรองตัวตนที่ดีขึ้น

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

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

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