Skip to content

captainthx/webauthn

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

WebAuthn Passkey Demo — Spring Boot REST API

Spring Boot 4 + Spring Security 7 application demonstrating WebAuthn (Passkey) authentication with a REST API backend, JPA persistence, and PostgreSQL.


📖 สารบัญ


🔐 ระบบ Authentication ทำงานยังไง?

1. Cookie-based Session คืออะไร?

ระบบนี้ใช้ Cookie-based Session (ไม่ใช่ JWT) ในการยืนยันตัวตน ซึ่งเป็น default ของ Spring Security

หลักการทำงานแบบง่ายๆ:

คิดเหมือน "บัตรเข้างาน Event"

1. คุณไปที่ประตู (Login) → ยื่นบัตรประชาชน (Email + Password)
2. เจ้าหน้าที่ตรวจสอบ → ถูกต้อง! → ให้ "สายรัดข้อมือ" (Session Cookie)
3. ตลอดทั้งงาน → แค่โชว์สายรัดข้อมือ → เข้าได้เลย ไม่ต้องยื่นบัตรอีก
4. ออกจากงาน (Logout) → ตัดสายรัดข้อมือทิ้ง → เข้าไม่ได้แล้ว

"สายรัดข้อมือ" = JSESSIONID Cookie
"รายชื่อผู้เข้างาน" = Session store ฝั่ง Server (เก็บใน Memory)

ในทาง Technical:

ส่วนประกอบ คำอธิบาย
JSESSIONID Cookie ที่ Server สร้างให้ Browser เก็บไว้ เป็นแค่ "ID สุ่ม" เช่น JSESSIONID=A1B2C3D4E5
HttpSession Object ฝั่ง Server ที่เก็บข้อมูล User (เช่น ใครล็อกอิน, มี Role อะไร)
SecurityContext Object ของ Spring Security ที่เก็บ Authentication info ไว้ใน Session
Browser                              Server (Spring Boot)
  │                                      │
  │  Request + Cookie:                   │
  │  JSESSIONID=A1B2C3D4E5              │
  │  ─────────────────────────────────►  │
  │                                      │  Server ดึง: "A1B2C3D4E5 = user@example.com, ROLE_USER"
  │                                      │  จาก HttpSession (เก็บใน Memory/Redis)
  │  ◄─────────────────────────────────  │
  │  Response: 200 OK (ข้อมูล User)      │

2. Session เกิดขึ้นยังไง?

เมื่อ Login สำเร็จ Spring Security จะทำ 3 สิ่งนี้:

// 1. ยืนยันตัวตน (Authenticate)
Authentication auth = authenticationManager.authenticate(
    new UsernamePasswordAuthenticationToken(email, password)
);

// 2. เก็บผลลัพธ์ใน SecurityContext
SecurityContextHolder.getContext().setAuthentication(auth);

// 3. บันทึกลง HttpSession (สร้าง Session ใหม่ถ้ายังไม่มี)
HttpSession session = request.getSession(true);
session.setAttribute("SPRING_SECURITY_CONTEXT", SecurityContextHolder.getContext());

ขั้นตอน 3 คือจุดสำคัญ! — Server สร้าง Session ID สุ่มขึ้นมา แล้วเก็บ Authentication object ไว้ในนั้น จากนั้นส่ง Session ID กลับไปเป็น Cookie

3. Cookie มาจากไหน?

Cookie ถูกสร้างโดย Tomcat (Servlet Container ที่ Spring Boot ใช้) อัตโนมัติ:

Login สำเร็จ:

Server Response Headers:
  HTTP/1.1 200 OK
  Set-Cookie: JSESSIONID=A1B2C3D4E5F6; Path=/; HttpOnly    ← ตัวนี้!
  Content-Type: application/json

Browser เห็น Header "Set-Cookie" → เก็บ Cookie ไว้อัตโนมัติ
→ ทุก Request ต่อไปที่ส่งไปยัง Server เดียวกัน จะแนบ Cookie นี้ไปด้วย

ในโปรเจ็กต์นี้มี Cookie 2 ตัว:

Cookie หน้าที่ สร้างโดยใคร
JSESSIONID Session ID — ใช้ระบุตัวตน User Tomcat (Servlet Container) สร้างอัตโนมัติ
XSRF-TOKEN CSRF Protection Token — ป้องกันการโจมตี Cross-Site Request Forgery Spring Security (CookieCsrfTokenRepository)

JSESSIONID สำคัญที่สุด — ถ้าไม่มี Cookie นี้ Server จะไม่รู้ว่าคุณเป็นใคร → ส่ง 401 Unauthorized กลับมา

XSRF-TOKEN ใช้เฉพาะกับ POST/PUT/DELETE — ต้องอ่าน Cookie แล้วส่งกลับไปใน Header X-XSRF-TOKEN เพื่อพิสูจน์ว่า Request มาจากหน้าเว็บจริงๆ ไม่ใช่จากเว็บอื่นปลอมมา

// ตัวอย่างการอ่าน XSRF-TOKEN แล้วส่งกลับใน Header
const xsrf = document.cookie
    .split("; ")
    .find(c => c.startsWith("XSRF-TOKEN="))
    ?.split("=")[1];

fetch("/login/webauthn", {
    method: "POST",
    credentials: "include",   // ← สำคัญ! บอกให้ Browser ส่ง Cookie ไปด้วย
    headers: {
        "Content-Type": "application/json",
        "X-XSRF-TOKEN": decodeURIComponent(xsrf)  // ← ส่ง CSRF token กลับไป
    },
    body: JSON.stringify(data)
});

4. เปรียบเทียบ Cookie Session vs JWT

┌─────────────────────────────────────────────────────────────────────────────┐
│                    Cookie-based Session (โปรเจ็กต์นี้)                      │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   Browser                         Server                                   │
│   ┌──────────┐                    ┌──────────────────────┐                 │
│   │ Cookie:  │   ── Request ──►   │ Session Store:       │                 │
│   │ JSESSION │                    │  A1B2 → {user: "a"}  │                 │
│   │ =A1B2    │   ◄── Response ──  │  C3D4 → {user: "b"}  │                 │
│   └──────────┘                    │  E5F6 → {user: "c"}  │                 │
│                                   └──────────────────────┘                 │
│   Cookie เก็บแค่ "ID"             Server เก็บข้อมูล User ทั้งหมด           │
│   ไม่มีข้อมูลอะไรเลย               ใน Memory (หรือ Redis)                  │
│                                                                             │
│   ✅ ง่าย, Spring Security ทำให้อัตโนมัติ                                   │
│   ✅ เพิกถอน (Logout) ได้ทันที — ลบ Session ฝั่ง Server                    │
│   ✅ ปลอดภัย — Cookie HttpOnly ป้องกัน XSS อ่านไม่ได้                      │
│   ❌ ไม่ Stateless — Server ต้องจำ Session ทุกคน                           │
│   ❌ Scale ยาก — ถ้ามีหลาย Server ต้องแชร์ Session (Redis)                 │
└─────────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────────┐
│                           JWT (JSON Web Token)                              │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   Browser                         Server                                   │
│   ┌──────────────────┐            ┌──────────────────────┐                 │
│   │ Header:          │ ─ Req ──►  │ ไม่ต้องเก็บอะไร!     │                 │
│   │ Authorization:   │            │ แค่ verify signature │                 │
│   │ Bearer eyJhbG... │ ◄─ Res ──  │ ด้วย Secret Key      │                 │
│   └──────────────────┘            └──────────────────────┘                 │
│                                                                             │
│   Token เก็บข้อมูล User           Server Stateless                         │
│   เข้ารหัส + ลายเซ็น              ไม่ต้องจำใครเลย                           │
│                                                                             │
│   ✅ Stateless — Server ไม่ต้องเก็บ Session                                │
│   ✅ Scale ง่าย — Server ตัวไหนก็ verify ได้                                │
│   ✅ เหมาะกับ Mobile App / Microservices                                    │
│   ❌ เพิกถอนยาก — Token ยังใช้ได้จนหมดอายุ (ต้องทำ Blacklist)              │
│   ❌ Token ใหญ่กว่า Session ID                                              │
│   ❌ ต้อง implement เอง — Spring Security ไม่ได้ทำให้อัตโนมัติ              │
└─────────────────────────────────────────────────────────────────────────────┘

🔄 Flow การทำงาน

Flow 1: Password Login (เข้าสู่ระบบด้วย Email/Password)

     Browser (login.html)                         Server (Spring Boot)
     ═══════════════════                           ════════════════════

 ┌─ 1. User กรอก Email + Password แล้วกด Login
 │
 │   POST /api/auth/login
 │   Body: {"email":"a@b.com","password":"1234"}
 │   ──────────────────────────────────────────►
 │                                                 ┌─ 2. AuthController.login()
 │                                                 │   authenticationManager.authenticate()
 │                                                 │     └─► JpaUserDetailsService.loadUserByUsername("a@b.com")
 │                                                 │           └─► SELECT * FROM app_users WHERE email = 'a@b.com'
 │                                                 │           └─► เปรียบเทียบ password (BCrypt)
 │                                                 │   ✅ ถูกต้อง!
 │                                                 │
 │                                                 │   SecurityContextHolder.getContext().setAuthentication(auth)
 │                                                 │   session.setAttribute("SPRING_SECURITY_CONTEXT", context)
 │                                                 │   ← Tomcat สร้าง JSESSIONID Cookie อัตโนมัติ
 │                                                 └─
 │
 │   ◄──────────────────────────────────────────
 │   200 OK
 │   Set-Cookie: JSESSIONID=ABC123; Path=/; HttpOnly
 │   Body: {"id":1,"email":"a@b.com","displayName":"John"}
 │
 └─ 3. Browser เก็บ JSESSIONID Cookie
    JavaScript: window.location.href = "/index.html"
    → redirect ไปหน้า Profile


 ┌─ 4. เข้าหน้า index.html → เรียก /api/auth/me
 │
 │   GET /api/auth/me
 │   Cookie: JSESSIONID=ABC123    ← Browser แนบ Cookie ไปอัตโนมัติ
 │   ──────────────────────────────────────────►
 │                                                 ┌─ 5. Spring Security Filter
 │                                                 │   อ่าน JSESSIONID=ABC123
 │                                                 │   หา Session → พบ SecurityContext
 │                                                 │   → User "a@b.com" ยังล็อกอินอยู่
 │                                                 │   ✅ ผ่าน!
 │                                                 │
 │                                                 │   AuthController.me()
 │                                                 │   → ดึง Principal จาก SecurityContext
 │                                                 │   → query DB → return User info
 │                                                 └─
 │
 │   ◄──────────────────────────────────────────
 │   200 OK {"id":1,"email":"a@b.com",...}
 │
 └─ 6. แสดงข้อมูล User บนหน้า Profile ✅

Flow 2: Passkey Registration (ลงทะเบียน Passkey)

⚠️ ต้อง Login ด้วย Password ก่อน ถึงจะ Register Passkey ได้

     Browser (index.html)                 Server                    Authenticator
     ════════════════════                 ══════                    (Windows Hello /
                                                                     Touch ID / YubiKey)
                                                                    ═══════════════

 ┌─ 1. User กดปุ่ม "Register New Passkey"
 │
 │   POST /webauthn/register/options
 │   Cookie: JSESSIONID=ABC123
 │   ──────────────────────────►
 │                                  ┌─ 2. Spring Security WebAuthn Filter
 │                                  │   สร้าง Challenge (ค่าสุ่ม)
 │                                  │   + ข้อมูล Relying Party (rpId, rpName)
 │                                  │   + ข้อมูล User (id, name)
 │                                  └─
 │   ◄──────────────────────────
 │   200 OK (PublicKeyCredentialCreationOptions)
 │   {challenge, rp, user, pubKeyCredParams,...}
 │
 │   3. JavaScript เรียก Web API:
 │   navigator.credentials.create({publicKey: options})
 │   ────────────────────────────────────────────────────────►
 │                                                              ┌─ 4. Authenticator
 │                                                              │   แสดง Prompt
 │                                                              │   (สแกนนิ้ว/Face/PIN)
 │                                                              │   ✅ User ยืนยัน!
 │                                                              │   สร้าง Key Pair:
 │                                                              │   - Private Key (เก็บในอุปกรณ์)
 │                                                              │   - Public Key (ส่งกลับ)
 │                                                              └─
 │   ◄────────────────────────────────────────────────────────
 │   credential (id, publicKey, attestation)
 │
 │   POST /webauthn/register
 │   Body: {credential, label: "My Passkey"}
 │   ──────────────────────────►
 │                                  ┌─ 5. Spring Security WebAuthn Filter
 │                                  │   ตรวจสอบ Challenge
 │                                  │   ตรวจสอบ Attestation
 │                                  │   บันทึก Public Key ลง DB
 │                                  │   (table: user_credentials)
 │                                  └─
 │   ◄──────────────────────────
 │   200 OK (Passkey saved!)
 │
 └─ 6. แสดง "Passkey registered! ✅"

Flow 3: Passkey Login (เข้าสู่ระบบด้วย Passkey)

🔑 ไม่ต้องกรอก Email/Password เลย!

     Browser (login.html)                 Server                    Authenticator
     ════════════════════                 ══════                    ═══════════════

 ┌─ 1. User กดปุ่ม "Login with Passkey"
 │
 │   POST /webauthn/authenticate/options
 │   Body: {}
 │   ──────────────────────────►
 │                                  ┌─ 2. Spring Security WebAuthn Filter
 │                                  │   สร้าง Challenge (ค่าสุ่มใหม่)
 │                                  │   + allowCredentials (Passkey ที่ลงทะเบียนไว้)
 │                                  └─
 │   ◄──────────────────────────
 │   200 OK (PublicKeyCredentialRequestOptions)
 │   {challenge, allowCredentials,...}
 │
 │   3. JavaScript เรียก Web API:
 │   navigator.credentials.get({publicKey: options})
 │   ────────────────────────────────────────────────────────►
 │                                                              ┌─ 4. Authenticator
 │                                                              │   แสดง Prompt
 │                                                              │   "เลือก Passkey"
 │                                                              │   (สแกนนิ้ว/Face/PIN)
 │                                                              │   ✅ User ยืนยัน!
 │                                                              │   ใช้ Private Key ที่เก็บไว้
 │                                                              │   เซ็น Challenge → Signature
 │                                                              └─
 │   ◄────────────────────────────────────────────────────────
 │   assertion (id, signature, authenticatorData, clientDataJSON)
 │
 │   POST /login/webauthn
 │   Body: {id, rawId, response: {signature, authenticatorData,...}}
 │   ──────────────────────────►
 │                                  ┌─ 5. Spring Security WebAuthn Filter
 │                                  │   ค้นหา Credential จาก DB
 │                                  │   ดึง Public Key ที่เก็บไว้
 │                                  │   Verify: signature + challenge + publicKey
 │                                  │   ✅ ถูกต้อง!
 │                                  │
 │                                  │   สร้าง WebAuthnAuthentication
 │                                  │   Principal = PublicKeyCredentialUserEntity  ← ‼️ ไม่ใช่ UserDetails!
 │                                  │   บันทึกลง Session (เหมือน Password Login)
 │                                  │   ← สร้าง JSESSIONID Cookie
 │                                  └─
 │   ◄──────────────────────────
 │   200 OK
 │   Set-Cookie: JSESSIONID=XYZ789; Path=/; HttpOnly
 │   Body: {"redirectUrl":"/","authenticated":true}
 │
 └─ 6. JavaScript: window.location.href = "/index.html"
    → redirect ไปหน้า Profile ✅

⚠️ จุดที่ต้องระวัง (Bug ที่เราแก้ไป):

เมื่อ Login ด้วย Password → Principal เป็น UserDetails เมื่อ Login ด้วย Passkey → Principal เป็น PublicKeyCredentialUserEntity

ดังนั้น Controller ต้องรองรับ ทั้ง 2 ประเภท (ดูใน AuthController.me() และ PasskeyController)

Flow 4: เข้าหน้า index.html (หลัง Login สำเร็จ)

     Browser (index.html)                         Server
     ════════════════════                          ══════

 ┌─ 1. Browser โหลด index.html (permitAll — ไม่ต้อง auth)
 │
 │   2. JavaScript ทำงาน:
 │      loadUser()  → GET /api/auth/me + Cookie: JSESSIONID=XYZ789
 │      loadPasskeys() → GET /api/passkeys + Cookie: JSESSIONID=XYZ789
 │      (Browser ส่ง Cookie ไปอัตโนมัติเพราะเป็น same-origin)
 │
 │   ──────────────────────────────────────────►
 │                                                ┌─ 3. Spring Security Filter Chain
 │                                                │   อ่าน JSESSIONID=XYZ789
 │                                                │   หา Session → พบ SecurityContext
 │                                                │   → User authenticated ✅
 │                                                │
 │                                                │   AuthController.me()
 │                                                │   → ดึง Principal (UserDetails หรือ WebAuthnUser)
 │                                                │   → resolveUsername() → หา email
 │                                                │   → query app_users → return User info
 │                                                │
 │                                                │   PasskeyController.listPasskeys()
 │                                                │   → resolveUsername() → หา email
 │                                                │   → query user_credentials → return passkey list
 │                                                └─
 │   ◄──────────────────────────────────────────
 │   /api/auth/me → 200 OK {email, displayName,...}
 │   /api/passkeys → 200 OK [{credentialId, label,...}]
 │
 └─ 4. แสดงข้อมูล User + Passkey List บนหน้า Profile ✅

🔄 แนวทางเปลี่ยนจาก Cookie Session ไปใช้ JWT

ถ้าต้องการเปลี่ยนจาก Cookie-based Session ไปใช้ JWT (เช่น สำหรับ Mobile App หรือ Microservices) ต้องเปลี่ยนส่วนต่างๆ ดังนี้:

สิ่งที่ต้องเพิ่ม/เปลี่ยน

1. เพิ่ม JWT Library (pom.xml)

<!-- เพิ่ม dependency -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>

2. สร้าง JWT Utility Class (ไฟล์ใหม่)

📁 สร้างไฟล์ใหม่: src/main/java/.../util/JwtUtil.java
// สร้าง class ที่สามารถ:
// - generateToken(String email) → สร้าง JWT Token
// - validateToken(String token) → ตรวจสอบว่า Token ถูกต้องไหม
// - getEmailFromToken(String token) → ดึง email จาก Token

3. สร้าง JWT Authentication Filter (ไฟล์ใหม่)

📁 สร้างไฟล์ใหม่: src/main/java/.../config/JwtAuthenticationFilter.java
// สร้าง Filter ที่ทำงานทุก Request:
// - อ่าน Header "Authorization: Bearer <token>"
// - Validate Token
// - ถ้าถูกต้อง → ตั้ง SecurityContext
//
// extends OncePerRequestFilter

4. แก้ไข SecurityConfig.java ⚠️ (เปลี่ยนเยอะที่สุด)

// เปลี่ยนจาก:
.formLogin(form -> form.loginPage("/login.html")...)

// เป็น:
.sessionManagement(session ->
    session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)  // ← ไม่สร้าง Session!
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
// ลบ .formLogin() ออก
// ลบ .logout() ออก (JWT ไม่ต้อง logout ฝั่ง Server)

5. แก้ไข AuthController.java

// เปลี่ยน login() ให้ return JWT Token แทน Session:

@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
    // Authenticate เหมือนเดิม
    Authentication auth = authenticationManager.authenticate(...);

    // ❌ ลบ: session.setAttribute(...)
    // ✅ เพิ่ม: สร้าง JWT Token
    String token = jwtUtil.generateToken(request.email());
    return ResponseEntity.ok(Map.of("token", token));
}

// เปลี่ยน me() ให้อ่านจาก SecurityContext (ไม่เปลี่ยนเยอะ เพราะแก้ไปแล้ว)

6. แก้ไข Frontend (login.html, index.html)

// เปลี่ยนจาก:
// (ไม่ต้องทำอะไร — Cookie ส่งอัตโนมัติ)

// เป็น:
// ต้องเก็บ Token ใน localStorage แล้วแนบทุก Request

// Login:
const data = await res.json();
localStorage.setItem("token", data.token);

// ทุก API call:
fetch("/api/auth/me", {
    headers: {
        "Authorization": "Bearer " + localStorage.getItem("token")
    }
});

// Logout:
localStorage.removeItem("token");

7. ⚠️ แก้ไข WebAuthn Authentication (ส่วนที่ยากที่สุด)

ปัญหา: Spring Security's WebAuthn filter ถูกออกแบบมาให้ใช้กับ Session
ไม่ได้ออกแบบมาให้ทำงานกับ JWT โดยตรง

ทางเลือก:
A) ใช้ "Hybrid" — WebAuthn ยังใช้ Session, แต่หลัง login สำเร็จ
   ให้สร้าง JWT Token แล้วส่งกลับไป (แนะนำ)
B) Override WebAuthnAuthenticationSuccessHandler ให้ return JWT
   แทนที่จะสร้าง Session
C) เขียน WebAuthn authentication เอง ไม่ใช้ built-in filter ของ Spring Security

สรุปไฟล์ที่ต้องแก้

 ไฟล์ที่ต้องแก้/เพิ่ม                          ระดับความยาก
 ═══════════════════════════════════════════════════════════
 📄 pom.xml                    → เพิ่ม jjwt dependency           🟢 ง่าย
 🆕 JwtUtil.java               → สร้างใหม่                       🟡 ปานกลาง
 🆕 JwtAuthenticationFilter.java → สร้างใหม่                     🟡 ปานกลาง
 📄 SecurityConfig.java        → เปลี่ยนเยอะ (STATELESS + Filter) 🔴 ยาก
 📄 AuthController.java        → แก้ login() return token        🟢 ง่าย
 📄 login.html                 → เก็บ token + ส่ง Header          🟡 ปานกลาง
 📄 index.html                 → ส่ง Authorization Header         🟡 ปานกลาง
 📄 WebAuthn Integration       → ปรับ success handler            🔴 ยาก

💡 คำแนะนำ: ถ้าเป็นเว็บ (Browser-based application) แนะนำให้ใช้ Cookie-based Session เพราะปลอดภัยกว่า (Cookie HttpOnly ป้องกัน XSS ได้) และ Spring Security ทำให้อัตโนมัติ ใช้ JWT เมื่อต้อง support Mobile App หรือ Microservices จริงๆ


Architecture

┌─────────────────────────────────────────────────────────┐
│  Spring Security WebAuthn                                │
│  ┌─────────────────────────────────────────────────────┐ │
│  │ PublicKeyCredentialUserEntityRepository (interface)  │ │
│  │ UserCredentialRepository (interface)                 │ │
│  └──────────────┬──────────────────────────────────────┘ │
│                 │ implements                             │
│  ┌──────────────▼──────────────────────────────────────┐ │
│  │ JpaPublicKeyCredentialUserEntityRepository (@Component)│
│  │ JpaUserCredentialRepository (@Component)            │ │
│  └──────────────┬──────────────────────────────────────┘ │
│                 │ uses                                    │
│  ┌──────────────▼──────────────────────────────────────┐ │
│  │ UserEntityJpaRepository (Spring Data JPA)           │ │
│  │ CredentialRecordJpaRepository (Spring Data JPA)     │ │
│  └──────────────┬──────────────────────────────────────┘ │
│                 │ maps                                    │
│  ┌──────────────▼──────────────────────────────────────┐ │
│  │ UserEntityRecord (@Entity)                          │ │
│  │ CredentialRecordEntity (@Entity)                    │ │
│  └──────────────┬──────────────────────────────────────┘ │
│                 │ Hibernate auto-DDL                      │
│            ┌────▼────┐                                   │
│            │PostgreSQL│                                   │
│            └─────────┘                                   │
└─────────────────────────────────────────────────────────┘

Prerequisites

  • Java 21+
  • Docker & Docker Compose
  • Maven (or use the included mvnw wrapper)

🚀 Quick Start

# 1. Start PostgreSQL
docker compose up -d

# 2. Run the application
./mvnw spring-boot:run
# Windows: .\mvnw spring-boot:run

# 3. Open test UI
# http://localhost:8080/login.html

Database

Setting Value
Host localhost:5432
Database webauthn
Username webauthn
Password webauthn
JDBC URL jdbc:postgresql://localhost:5432/webauthn

Tables are auto-created by Hibernate (ddl-auto: update):

Table Description
app_users Application users (email/password)
user_entities WebAuthn user entities (passkey owner)
user_credentials WebAuthn credentials (passkeys)

📡 REST API

Authentication

Register

curl -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "email": "user@example.com",
    "password": "MyPassword123",
    "displayName": "John Doe"
  }'

Response 201 Created:

{
  "id": 1,
  "email": "user@example.com",
  "displayName": "John Doe",
  "createdAt": "2026-02-12T07:00:00Z"
}

Errors:

  • 400 — Validation error (missing email, password < 8 chars)
  • 409 — Email already registered

Login

curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -c cookies.txt \
  -d '{
    "email": "user@example.com",
    "password": "MyPassword123"
  }'

Response 200 OK + Set-Cookie: JSESSIONID=...:

{
  "id": 1,
  "email": "user@example.com",
  "displayName": "John Doe",
  "createdAt": "2026-02-12T07:00:00Z"
}

Error 401:

{ "error": "AUTH_FAILED", "message": "Invalid email or password" }

Get Current User

curl http://localhost:8080/api/auth/me -b cookies.txt

Response 200 OK:

{
  "id": 1,
  "email": "user@example.com",
  "displayName": "John Doe",
  "createdAt": "2026-02-12T07:00:00Z"
}

Logout

curl -X POST http://localhost:8080/api/auth/logout -b cookies.txt

Response 200 OK:

{ "message": "Logged out successfully" }

Passkeys

List Passkeys

curl http://localhost:8080/api/passkeys -b cookies.txt

Response 200 OK:

[
  {
    "credentialId": "NxxppSNYduUBLSESigyfCA",
    "label": "My Passkey",
    "created": "2026-02-12T07:05:00Z",
    "lastUsed": "2026-02-12T07:10:00Z"
  }
]

Delete Passkey

curl -X DELETE http://localhost:8080/api/passkeys/{credentialId} \
  -b cookies.txt \
  -H "X-XSRF-TOKEN: <token>"

Response 200 OK:

{ "message": "Passkey deleted successfully" }

WebAuthn Flows (Spring Security Built-in)

These endpoints are handled by Spring Security's WebAuthn filters.

Passkey Registration Flow

Client                          Server
  │                                │
  │  POST /webauthn/register/options
  │  (authenticated, + XSRF token) │
  │ ──────────────────────────────►│
  │                                │
  │  ◄─── PublicKeyCredentialCreationOptions (challenge, rp, user)
  │                                │
  │  navigator.credentials.create()│
  │  (browser prompts biometric)   │
  │                                │
  │  POST /webauthn/register       │
  │  (credential + XSRF token)    │
  │ ──────────────────────────────►│
  │                                │
  │  ◄─── 200 OK (passkey saved)  │

Passkey Authentication Flow

Client                          Server
  │                                │
  │  POST /webauthn/authenticate/options
  │  (public, no auth needed)      │
  │ ──────────────────────────────►│
  │                                │
  │  ◄─── PublicKeyCredentialRequestOptions (challenge, allowCredentials)
  │                                │
  │  navigator.credentials.get()   │
  │  (browser prompts biometric)   │
  │                                │
  │  POST /login/webauthn          │
  │  (assertion response)         │
  │ ──────────────────────────────►│
  │                                │
  │  ◄─── 200 OK + session cookie │

📁 Project Structure

src/main/java/dev/yutsuki/webauthn/
├── WebauthnApplication.java          # Spring Boot entry point
├── config/
│   └── SecurityConfig.java          # Security: CORS, CSRF, auth, WebAuthn
├── controller/
│   ├── AuthController.java          # /api/auth/* (register, login, logout, me)
│   └── PasskeyController.java       # /api/passkeys (list, delete)
├── dto/
│   ├── RegisterRequest.java         # { email, password, displayName }
│   ├── LoginRequest.java            # { email, password }
│   ├── UserResponse.java           # { id, email, displayName, createdAt }
│   ├── PasskeyResponse.java        # { credentialId, label, created, lastUsed }
│   └── ApiError.java               # { error, message }
├── entity/
│   ├── AppUser.java                 # app_users table
│   ├── UserEntityRecord.java        # user_entities table (WebAuthn)
│   └── CredentialRecordEntity.java  # user_credentials table (WebAuthn)
├── repository/
│   ├── AppUserRepository.java                          # Spring Data JPA for AppUser
│   ├── UserEntityJpaRepository.java                    # Spring Data JPA for UserEntityRecord
│   ├── CredentialRecordJpaRepository.java              # Spring Data JPA for CredentialRecordEntity
│   ├── JpaPublicKeyCredentialUserEntityRepository.java # Adapter → Spring Security
│   └── JpaUserCredentialRepository.java                # Adapter → Spring Security
└── service/
    └── JpaUserDetailsService.java    # UserDetailsService backed by PostgreSQL

Test UI

Static HTML pages are included at /login.html and /index.html for testing. They call the REST API endpoints — they are not required for production use.


📚 แหล่งอ้างอิง

WebAuthn / Passkeys

Spring Security WebAuthn

Spring Security Session Management

Cookie & CSRF

JWT (สำหรับเปรียบเทียบ/เปลี่ยนไปใช้)

Releases

No releases published

Packages

 
 
 

Contributors