โหมดมืด
บทที่ 7 — OAuth2 (Login with Google/GitHub + Resource Server — server ที่เก็บข้อมูลและเช็ค token ที่ client ส่งมา)
บทที่ 3 เรียน JWT แบบเขียนเอง — เหมาะกับ app ที่ user ลงทะเบียนกับเราตรง ๆ
บทนี้เรียน OAuth2 — มาตรฐานสำหรับ:
- "Login with Google / Facebook / GitHub" (Authorization Code flow — ลำดับขั้นตอนการขอ code ก่อน แล้วแลก token)
- API-to-API auth (Client Credentials flow — ลำดับสำหรับ server-to-server ไม่มี user เกี่ยวข้อง)
- Resource Server ที่ verify JWT จาก provider อื่น
หลังจบบท คุณจะ:
- เข้าใจ OAuth2 flow ที่ใช้จริง 3 แบบ + flow ที่ deprecated อีก 2 แบบ (Implicit, Password Grant)
- ทำ "Login with Google" ใน Spring Boot
- ทำ Resource Server ที่ verify token จาก Keycloak/Auth0
- เข้าใจ OIDC + JWT (token อ่านข้างในได้) vs Opaque token (token ทึบ — อ่านข้างในไม่ได้)
🚧 โซนขั้นสูง — ข้ามได้
บทนี้เป็น บทอ้างอิงระดับลึก ถ้าคุณเพิ่งเริ่มและยังไม่ต้องทำ "Login ด้วย Google/Facebook" หรือเชื่อมระบบหลายตัวเข้าด้วยกัน ข้ามไปก่อนได้ — JWT แบบเขียนเองในบทที่ 3 เพียงพอสำหรับแอปที่ user สมัครกับเราตรง ๆ กลับมาอ่านบทนี้เมื่อต้องต่อกับ identity provider ภายนอก
อ่านบทนี้เมื่อ:
- ต้องการปุ่ม "Login with Google / Facebook / LINE"
- ต้องการให้หลาย service ใช้ token เดียวกัน (Single Sign-On)
- ใช้ Keycloak / Auth0 / Okta แทนระบบ auth ของตัวเอง
📋 ก่อนเริ่ม + ศัพท์ปูพื้น
- ✅ ต้องผ่าน: Spring Boot บทที่ 3 (Security + JWT)
🔑 ศัพท์หลัก (ต้องรู้ก่อน):
- Lombok (ลอมบ็อก) = library ที่ generate getter/setter/constructor ให้อัตโนมัติ ใช้บ่อยในบทนี้ (
@Getter,@Setter,@AllArgsConstructor) — อ่านที่มาได้ที่ บทที่ 1 § เครื่องมือ- OAuth2 (โอ-ออธ-ทู) = มาตรฐานการ "ขออนุญาตเข้าถึงข้อมูล" โดยไม่ต้องบอก password ให้ app อื่น
- scope (สโคป) = ขอบเขตสิทธิ์ที่ขอ (เช่น อ่าน email ได้ แต่โพสต์ไม่ได้)
- OIDC (โอ-ไอ-ดี-ซี, OpenID Connect) = ชั้นที่ต่อยอด OAuth2 ให้ "รู้ว่า user เป็นใคร" (login) — ใช้ในหัวข้อบทนี้และ Section 4
📚 ศัพท์รอง (อ่านผ่าน — กลับมาดูได้):
- provider / IdP (Identity Provider — ผู้ให้บริการพิสูจน์ตัวตน) = เจ้าของระบบ login เช่น Google, Keycloak
- flow (โฟลว์ — ลำดับขั้นตอน) = ลำดับขั้นตอนการแลก token แต่ละแบบ
1. ทำไมต้องมี OAuth2
สมัยก่อนถ้า app ต้องการ data ของคุณบน Facebook:
text
"กรอก username/password Facebook ของคุณที่นี่"อันตราย:
- App รู้ password คุณ — เปลี่ยน password ก็ไม่ได้
- ใช้ scope ครบทุกอย่าง — ไม่จำกัดสิทธิ์
OAuth2 = "User อนุญาตให้ app อ่าน data ของฉันบน Facebook โดยไม่ต้องให้ password"
2. คำศัพท์ OAuth2
| คำ | คำไทย | ตัวอย่าง |
|---|---|---|
| Resource Owner | เจ้าของข้อมูล | User (คุณ) |
| Client | แอปของเรา (ผู้ขอเข้าถึง) | App ของเรา (เช่น Spotify) |
| Resource Server | server ที่เก็บข้อมูล | API ที่เก็บ data (Facebook API) |
| Authorization Server | server ที่ออก token | Facebook Login, Google OAuth |
| Access Token | บัตรชั่วคราวใช้เรียก resource | บัตรชั่วคราว — ใช้เรียก resource |
| Refresh Token | บัตรขอบัตรใหม่ (อายุยาว) | บัตร "ขอบัตรใหม่" — อายุยาวกว่า |
| Scope | ขอบเขตสิทธิ์ที่ขอ | สิทธิ์ที่ขอ (read:photos, write:posts) |
ใน app เล็ก ๆ Authorization Server + Resource Server อาจเป็น server เดียวกัน
3. OAuth2 Flow 4 แบบ
A. Authorization Code (Web app + Backend)
ใช้เมื่อ: ทำ "Login with Google" ใน Spring Boot
Secret อยู่ฝั่ง backend → ปลอดภัย
B. Authorization Code + PKCE (SPA — Single-Page App / Mobile)
PKCE (พิก-ซี — Proof Key for Code Exchange — "กุญแจพิสูจน์ตอนแลก code")
เหมาะกับ SPA (Single-Page App — เว็บที่ render ฝั่ง browser ทั้งหมด) หรือ mobile app ที่ไม่มี backend คอยเก็บ secret ให้
app แบบนี้ ไม่สามารถเก็บ client_secret ได้อย่างปลอดภัย เพราะโค้ดทั้งหมดรันอยู่ใน browser หรือมือถือของ user เอง — ใครก็ตามที่เปิด dev tools หรือ decompile แอปก็เห็น secret ได้ทันที
แทนที่จะใช้ client_secret → client สุ่ม code_verifier (กุญแจที่สุ่มขึ้นเอง) + คำนวณ code_challenge (ผลแฮชของกุญแจ) เพื่อพิสูจน์ตอนแลก code
- SHA256 = สูตรคำนวณที่แปลงข้อความยาวเป็นรหัสสั้นที่ตายตัวและย้อนกลับไม่ได้ — ใช้เทียบว่าค่าตรงกันโดยไม่ต้องส่งค่าจริง (ต่อให้ใครดักฟังก็เอา
code_verifierตัวจริงกลับไม่ได้) - BASE64URL = วิธีเข้ารหัสผลลัพธ์ให้เป็นตัวอักษรที่ปลอดภัยสำหรับใส่ใน URL (ไม่มีอักขระพิเศษที่ URL ตีความผิด)
text
code_verifier = random string ยาว 43-128 ตัว
code_challenge = BASE64URL(SHA256(code_verifier))
ขั้นแรก: ส่ง code_challenge ไปพร้อม authorize request
ขั้นแลก: ส่ง code_verifier ตัวจริง — server ตรวจสอบว่า hash ตรงกัน📎 ดู บท 3 § 13.7 สำหรับ flow ละเอียดและตัวอย่าง code_verifier/code_challenge
ปี 2026 = standard ของ frontend OAuth
C. Client Credentials (Server-to-Server)
ใช้เมื่อ: Service A เรียก Service B — ไม่มี user เกี่ยวข้อง
text
1. Service A → POST /token (client_id + client_secret + grant_type=client_credentials)
2. ได้ access_token
3. ใช้เรียก Service BD. Implicit / Password Grant — ❌ deprecated
Implicit flow คือ token ออกมาตรง ๆ โดยไม่มีขั้นแลก code (ไม่ปลอดภัยเพราะ token หลุดผ่าน URL ได้) ส่วน Resource Owner Password Credentials (ROPC) คือผู้ใช้กรอก password ให้ app ตรง ๆ (ขัดกับหลักการ OAuth ที่ไม่ควรให้ app รู้ password)
OAuth 2.1 ลบ Implicit + ROPC ออกจากมาตรฐาน — Security Best Current Practice (BCP) ก็ deprecate flow เหล่านี้ไปแล้ว ไม่ควรใช้ในระบบใหม่ (ตรวจสอบสถานะล่าสุดของ OAuth 2.1 ได้ที่ oauth.net/2.1)
4. OAuth2 vs OIDC
| OAuth2 | OIDC | |
|---|---|---|
| ใช้ทำอะไร | Authorization (ขอสิทธิ์/อนุญาต) | Authentication (พิสูจน์ตัวตน) + Authorization (ขอสิทธิ์) |
| Token | Access Token | Access Token + ID Token (JWT ที่เก็บข้อมูล user เช่น email/name) |
| ตัวอย่าง | "อนุญาตให้ app เข้าถึง photos" | "Login with Google" |
OIDC = OAuth2 + identity layer
ID Token เป็น JWT มี info ของ user:
json
{
"iss": "https://accounts.google.com",
"sub": "1234567890",
"email": "anna@gmail.com",
"name": "Anna",
"picture": "https://...",
"exp": 1747562400
}Part 2: Login with Google (OAuth2 Client)
5. Setup
จะทำ "Login with Google" ต้องเพิ่ม starter 2 ตัว
oauth2-client— ทำหน้าที่เป็น OAuth client ไปคุยกับ provider (Google/GitHub/...)security— ฐาน Spring Security ที่ทุกอย่างวางอยู่บน
หลังใส่ 2 ตัวนี้ Spring จะจัดการ flow ของ OAuth ให้เกือบทั้งหมดโดยอัตโนมัติ เหลือแค่ตั้งค่า:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>6. ลงทะเบียน OAuth Client กับ Google
ก่อนใช้ ต้องลงทะเบียน app กับ provider เพื่อให้ได้ client_id + client_secret — ขั้นตอนสำคัญคือตั้ง "redirect URI" ให้ตรงกับที่ Spring ใช้ (/login/oauth2/code/google) ไม่งั้น Google จะไม่ยอม redirect กลับ:
- ไป https://console.cloud.google.com → สร้าง project ใหม่ (ถ้ายังไม่มี)
- APIs & Services → OAuth consent screen → เลือก External → กรอกชื่อแอป (หน้าตา UI อาจเปลี่ยนได้ตาม Google update)
- APIs & Services → Credentials → Create OAuth client ID → Web application
- Authorized redirect URIs:
http://localhost:8080/login/oauth2/code/googlepath นี้เป็น endpoint ที่ Spring Boot สร้างให้อัตโนมัติ จาก
spring-boot-starter-oauth2-clientไม่ต้องเขียน controller เอง - ได้
client_id+client_secret
7. Configuration
ตั้งค่า OAuth client ใน application.yml — ใส่ client-id/secret + scope ที่ต้องการ (openid/profile/email) ข้อดีคือ Spring รู้จัก provider ดัง ๆ (Google/GitHub) อยู่แล้ว ไม่ต้องระบุ endpoint URL เอง แค่ใส่ credential ก็พอ:
yaml
# application.yml
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope:
- openid
- profile
- email
github: # ⚠️ ถ้ายังไม่ได้ลงทะเบียน GitHub ให้ลบ block นี้ออก
client-id: ${GITHUB_CLIENT_ID} # ถ้า env var นี้ไม่ได้ตั้ง แอปจะ start ไม่ขึ้น
client-secret: ${GITHUB_CLIENT_SECRET}
# ลงทะเบียนได้ที่: GitHub Settings → Developer settings → OAuth Apps
scope:
- user:email
- read:user
${GOOGLE_CLIENT_ID}= อ่านค่าจาก environment variable (ตัวแปรสิ่งแวดล้อม) — อย่า hardcode ลงใน file โดยตรง ตัวอย่างตั้งค่าก่อนรัน: PowerShell$env:GOOGLE_CLIENT_ID="xxx"หรือตั้งใน IDE run configurationGoogle/GitHub provider มี default provider config ใน Spring — ไม่ต้องระบุ endpoint เอง
8. SecurityConfig
java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/error", "/webjars/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth -> oauth
.defaultSuccessUrl("/dashboard", true)
)
.logout(logout -> logout
.logoutSuccessUrl("/")
);
return http.build();
}
}ทันทีที่ user เข้า /dashboard (หรือ url อื่นที่ต้อง auth) → redirect ไป /oauth2/authorization/google → Google login → callback → ได้ session
path
/oauth2/authorization/googleเป็น default endpoint ที่ Spring Security สร้างให้อัตโนมัติ จากoauth2Login()ตามชื่อ registration ใน yaml (
📝
SecurityConfigนี้เป็นเวอร์ชันเริ่มต้น — ใน §10 เราจะอัปเดตให้รวมCustomOidcUserServiceด้วย
9. ดึง User Info
หลัง login สำเร็จ Spring เก็บข้อมูล user จาก provider ไว้ให้ — ดึงผ่าน @AuthenticationPrincipal OAuth2User (หรือ OidcUser สำหรับ OIDC) ที่มี attribute เช่น name/email/picture เอาไปแสดงหรือ map เข้า user ในระบบเราต่อ:
java
@RestController
public class UserController {
@GetMapping("/me")
public Map<String, Object> me(@AuthenticationPrincipal OAuth2User user) {
return Map.of(
"name", user.getAttribute("name"),
"email", user.getAttribute("email"),
"picture", user.getAttribute("picture")
);
}
@GetMapping("/oidc-info")
public Map<String, Object> oidcInfo(@AuthenticationPrincipal OidcUser user) {
return Map.of(
"sub", user.getSubject(),
"email", user.getEmail(),
"name", user.getFullName(),
"idToken", user.getIdToken().getTokenValue()
);
}
}OAuth2User = generic, OidcUser = มี method สำเร็จรูป (getEmail(), getSubject())
10. บันทึก User ลง DB ครั้งแรก
OAuth2 ยืนยันตัวตนให้แต่ไม่บันทึก user ลง DB ของเราให้ — ต้อง hook เองโดย extend OidcUserService แล้วเช็ค: ถ้า email นี้ยังไม่มีในระบบก็สร้างใหม่ (provisioning ครั้งแรก — การสร้าง account ในระบบเราอัตโนมัติตอน login ครั้งแรก) เป็นจุดที่ map ตัวตนจาก provider เข้า user ของแอป:
📝 บทนี้ใช้
Userentity ของบทนี้เอง (ไม่ใช่ตัวเดียวกับบท 3) — เพื่อรองรับ field ที่ OAuth/OIDC ใช้ (email, fullName) แยกออกจาก username/password ของ JWT แบบเขียนเองในบท 3:
Java ไม่มี property (
get/setในตัวภาษาแบบบางภาษา) — ต้องมี methodgetX()/setX()จริง ๆ เสมอ@Getter @Setterของ Lombok แค่ generate method พวกนี้ให้อัตโนมัติตอน compile เท่านั้น ไม่ใช่ feature พิเศษของภาษา
java
// User entity เฉพาะของบทนี้ — self-contained
@Entity
@Table(name = "oauth_users")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class User {
@Id @GeneratedValue
private Long id;
@Column(unique = true, nullable = false)
private String email;
private String fullName;
private String role; // "USER", "ADMIN", ...
}
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}java
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class CustomOidcUserService extends OidcUserService {
private final UserRepository userRepo;
// OAuth2AuthenticationException เป็น "checked exception" — Java บังคับให้ประกาศ `throws` ไว้ที่ signature
// ถ้าไม่ประกาศจะ compile ไม่ผ่าน ต่างจาก DataIntegrityViolationException ด้านล่างซึ่งเป็น "unchecked"
// (RuntimeException) — โยนได้โดยไม่ต้องประกาศ `throws` เลย
@Override
@Transactional // ต้องมี transaction เพราะ userRepo.save() ถูกเรียกใน Spring Security filter chain
public OidcUser loadUser(OidcUserRequest request) throws OAuth2AuthenticationException {
OidcUser oidcUser = super.loadUser(request);
String email = oidcUser.getEmail();
// กรณี 2 request เข้าพร้อมกัน (double-click / retry) อาจเกิด duplicate insert ได้
// → ใช้ optimistic upsert: ลอง save ก่อน ถ้า unique constraint ชนกัน ค่อย findByEmail อีกครั้ง
userRepo.findByEmail(email).orElseGet(() -> {
try {
// @AllArgsConstructor (Lombok) บน User entity สร้าง constructor นี้ให้อัตโนมัติ
// (ดู User entity ด้านบน — ต้องมี @AllArgsConstructor ไม่งั้น compile ไม่ผ่าน)
return userRepo.save(new User(null, email, oidcUser.getFullName(), "USER"));
} catch (org.springframework.dao.DataIntegrityViolationException e) {
// race condition: request อื่น insert ก่อนแล้ว → ดึงค่าที่มีอยู่แล้วกลับมา
return userRepo.findByEmail(email).orElseThrow();
}
});
return oidcUser;
}
}Wire ใน SecurityConfig — ต้อง inject CustomOidcUserService เข้า SecurityConfig ก่อน:
java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import lombok.RequiredArgsConstructor;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomOidcUserService customOidcUserService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/error", "/webjars/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth -> oauth
.defaultSuccessUrl("/dashboard", true)
.userInfoEndpoint(u -> u.oidcUserService(customOidcUserService))
)
.logout(logout -> logout
.logoutSuccessUrl("/")
);
return http.build();
}
}Part 3: Resource Server — verify JWT จาก provider
11. ใช้เมื่อไหร่?
มี Auth Provider ภายนอก (Keycloak, Auth0, AWS Cognito, Okta) ออก JWT
Spring Boot ของเรา = Resource Server ที่ verify token แล้วให้ access
text
Frontend → POST /login → Keycloak → JWT
Frontend → GET /api/users (Bearer JWT) → Spring Boot
↓
verify JWT (public key)
↓
allow / deny12. Setup
ถ้าระบบเราเป็น Resource Server (verify JWT ที่ provider อื่นออก) ใช้ starter oauth2-resource-server — แค่ตั้ง issuer-uri Spring จะ fetch public key มา verify ลายเซ็น JWT ให้อัตโนมัติ ไม่ต้องเขียน filter เอง (ต่างจากบท 03 ที่เราออก JWT เอง):
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>yaml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://your-keycloak.com/realms/myrealm
# หรือ
jwk-set-uri: https://your-keycloak.com/realms/myrealm/protocol/openid-connect/certsSpring จะ fetch public key อัตโนมัติจาก
issuer-uri→ cache → ใช้ verify JWT signature
13. SecurityConfig
config ของ Resource Server — ตั้ง stateless (ไม่มี session, ทุก request พึ่ง JWT) + oauth2ResourceServer().jwt() จุดที่มักต้องเขียนเองคือ converter ที่แปลง claim (เคลม — ข้อมูลแต่ละชิ้นที่ token พกพา เช่น sub, email, roles) ของ provider (เช่น realm_access.roles ของ Keycloak) เป็น Spring authorities (สิทธิ์ในรูปแบบที่ Spring เข้าใจ) เพื่อใช้กับ @PreAuthorize:
java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth -> oauth
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
);
return http.build();
}
// แปลง JWT claim "realm_access.roles" → Spring authorities
//
// คำอธิบาย: Keycloak ใส่ role ใน JWT ใต้ claim "realm_access.roles"
// - claim (เคลม) = ข้อมูลแต่ละชิ้นที่ token พกพา (เช่น sub, email, roles)
// - realm (เรล์ม) = "ขอบเขต" ของ Keycloak instance — 1 realm = 1 กลุ่ม user/role
// แต่ Spring ต้องการ "authorities" (รายการสิทธิ์) ที่ทุกตัวขึ้นต้นด้วย "ROLE_"
// → ต้องเขียน converter (ตัวแปลงรูปแบบ) เอง
@Bean
@SuppressWarnings("unchecked")
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter c = new JwtAuthenticationConverter();
c.setJwtGrantedAuthoritiesConverter(jwt -> {
Map<String, Object> realmAccess = jwt.getClaim("realm_access");
if (realmAccess == null) return List.of();
// null-guard: ถ้า key "roles" มีแต่ค่าเป็น null ก็คืน empty list (ป้องกัน NullPointerException)
Object rolesObj = realmAccess.get("roles");
List<String> roles = rolesObj instanceof List<?> list
? (List<String>) list
: List.of();
return roles.stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r))
.collect(Collectors.toList());
});
return c;
}
}14. ใช้ใน Controller
ใน controller ดึงข้อมูลจาก JWT ที่ verify แล้วผ่าน @AuthenticationPrincipal Jwt (อ่าน claim ได้ตรง ๆ เช่น subject, email) และคุมสิทธิ์ด้วย @PreAuthorize เหมือนเดิม — เพราะ converter ในข้อก่อนหน้าแปลง role ให้แล้ว:
java
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import lombok.RequiredArgsConstructor;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("/me")
public Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) {
// ⚠️ หมายเหตุ: Keycloak เก็บ roles ใต้ realm_access.roles ไม่ใช่ top-level claim "roles"
// converter ใน §13 แปลงเป็น Spring authorities แล้ว ถ้าต้องการ roles ให้ดูผ่าน SecurityContext
// ตัวอย่างนี้แสดงการอ่าน claim ตรง ๆ — ใช้ได้กับ provider ที่ใส่ "roles" ระดับบนสุด
return Map.of(
"sub", jwt.getSubject(),
"email", jwt.getClaimAsString("email")
);
}
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public List<User> getAll() {
return userService.findAll();
}
@PostMapping
@PreAuthorize("hasAuthority('SCOPE_write')") // ต้องการ scope "write" ถึงจะสร้าง user ได้
public User create(@RequestBody User user) {
return userService.save(user);
}
}15. ส่ง Request
ฝั่ง client แค่แนบ JWT ที่ได้จาก provider ใน Authorization: Bearer header — Spring จะ verify ลายเซ็น, เช็ค expiry, แปลง claim เป็น authentication ให้ทั้งหมดอัตโนมัติก่อนถึง controller:
bash
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \
http://localhost:8080/api/users/meSpring จะ:
- อ่าน Authorization header
- parse JWT
- ดึง public key จาก issuer (cache)
- verify signature + expiry
- set
Authenticationใน SecurityContext - controller ดึงด้วย
@AuthenticationPrincipal Jwt
16. Opaque Token (alternative)
Opaque Token (โอ-เพค โทเค่น — "token ทึบ" คือ token ที่ อ่านข้างในไม่ได้ตรง ๆ ต่างจาก JWT ที่ decode อ่าน claim ได้)
บาง provider ออก token แบบนี้ — เป็น random string (สตริงสุ่มที่ไม่มีโครงสร้าง) ที่ตัวมันเองไม่บอกอะไร Resource Server ต้องเรียก provider ผ่าน introspection endpoint (endpoint ที่ provider เปิดให้สอบถามว่า "token นี้ยังใช้ได้ไหม + เป็นของใคร") ทุกครั้งที่ verify
yaml
spring:
security:
oauth2:
resourceserver:
opaquetoken:
introspection-uri: https://provider.com/oauth2/introspect
client-id: ${CLIENT_ID}
client-secret: ${CLIENT_SECRET}| JWT | Opaque | |
|---|---|---|
| Verify | local (signature) | call provider |
| Speed | เร็ว | ช้า + network |
| Revoke | ไม่ได้ตรง ๆ — รอ expire | revoke ทันทีได้ |
| Size | ใหญ่ (~1 KB) | เล็ก (~40 chars) |
ส่วนใหญ่ใช้ JWT + อายุสั้น (15 นาที) + refresh token
Part 4: Real-world scenarios
17. Scenario A: SPA + Spring Boot API (PKCE)
text
React SPA → Keycloak (PKCE flow) → Access Token (JWT)
React → GET /api/users (Bearer JWT) → Spring Boot Resource ServerSpring Boot: Resource Server config ที่กล่าวไว้ในส่วน Resource Server ข้างบน React: ใช้ react-oidc-context (wrap ของ oidc-client-ts) — รองรับ PKCE auto
tsx
// main.tsx
import { AuthProvider } from "react-oidc-context";
const oidcConfig = {
authority: "https://keycloak.example.com/realms/myapp",
client_id: "spa-client",
redirect_uri: window.location.origin + "/callback",
response_type: "code", // ⭐ Authorization Code
scope: "openid profile email",
// PKCE เปิดอัตโนมัติโดย library
automaticSilentRenew: true, // ต่ออายุ token เงียบ ๆ (silent refresh)
loadUserInfo: true,
};
createRoot(document.getElementById("root")!).render(
<AuthProvider {...oidcConfig}>
<App />
</AuthProvider>
);tsx
// App.tsx
import { useAuth } from "react-oidc-context";
function App() {
const auth = useAuth();
if (auth.isLoading) return <div>Loading...</div>; {/* ข้อความที่แสดงบนหน้าเว็บ — เปลี่ยนเป็นภาษาไทยได้ */}
if (auth.error) return <div>Error: {auth.error.message}</div>; {/* ข้อความ error บนหน้าเว็บ ไม่ใช่ error ของ Spring */}
if (!auth.isAuthenticated) {
return <button onClick={() => auth.signinRedirect()}>Login</button>;
}
return (
<div>
<p>Hi {auth.user?.profile.name}</p>
<button onClick={() => auth.signoutRedirect()}>Logout</button>
<UserList token={auth.user!.access_token} />
</div>
);
}
// ⚠️ ทางเลือกที่ดีกว่า: ใช้ useAuth() ใน UserList โดยตรงแทนการรับ token เป็น prop
// เพื่อลด surface area (พื้นที่เสี่ยง — จำนวนจุดที่ข้อมูลอ่อนไหวอาจรั่วออกไปได้) ที่ token จะถูก expose ใน component tree (XSS risk)
// (ใน App.tsx จริง ให้เพิ่ม import ที่ต้นไฟล์: import { useState, useEffect } from "react";)
type User = { id: number; name: string };
function UserList({ token }: { token: string }) {
const [users, setUsers] = useState<User[]>([]);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetch("/api/users", { headers: { Authorization: `Bearer ${token}` } })
.then((r) => { if (!r.ok) throw new Error(r.statusText); return r.json(); })
.then(setUsers)
.catch(setError);
}, [token]);
if (error) return <div>โหลดข้อมูลไม่สำเร็จ: {error.message}</div>;
return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}🔗 ผูกกับ บท 3 §13.7 PKCE — เนื้อหา flow + code_verifier/code_challenge
17.1 Testing OAuth2 Resource Server
ทดสอบ endpoint ที่ require JWT โดยไม่ต้องมี real authority ก่อนอื่นต้องเพิ่ม dependency สำหรับ test:
xml
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>java
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
// static import นี้ทำให้เรียก jwt() สั้น ๆ ได้เลย
@WebMvcTest(UserController.class)
@Import(ResourceServerConfig.class) // ใช้ ResourceServerConfig ไม่ใช่ SecurityConfig จาก §8
class UserControllerTest {
@Autowired MockMvc mvc;
@Test
void requiresAuth() throws Exception {
mvc.perform(get("/api/users")).andExpect(status().isUnauthorized());
}
@Test
void allowsValidJwtForAdmin() throws Exception {
mvc.perform(get("/api/users")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
.andExpect(status().isOk());
}
@Test
void rejectsMissingWriteScope() throws Exception {
// POST /api/users ต้องการ SCOPE_write (ดู controller §14)
// ถ้าส่งแค่ ROLE_ADMIN (ไม่มี SCOPE_write) จะได้ 403
mvc.perform(post("/api/users").contentType("application/json").content("{}")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
.andExpect(status().isForbidden());
}
}.with(jwt()) — เป็น static import จาก SecurityMockMvcRequestPostProcessors — ฉีด JwtAuthenticationToken เข้า SecurityContext โดยไม่ต้องมี JWT จริง / authority server จริง
สำหรับ OAuth2 client (login flow): ใช้ oauth2Login() post-processor + MockOAuth2Server (artifact: no.nav.security:mock-oauth2-server — library สำหรับจำลอง OAuth2/OIDC server ในการทดสอบ)
18. Scenario B: Login with Google + custom JWT
รูปแบบที่พบบ่อยในงานจริง — ใช้ Google ยืนยันตัวตนตอน login แต่หลังจากนั้นออก JWT ของระบบเราเองให้ client ใช้ (ไม่ใช้ token ของ Google ตรง ๆ) ได้ทั้งความสะดวกของ social login + ความยืดหยุ่นของ token ที่เราคุมเอง (ผสม OAuth2 client กับ JWT จากบท 3):
text
React → "Login with Google" → Google OAuth → callback ที่ Spring Boot
Spring Boot → verify Google token → ดึง email → สร้าง user → ออก JWT ของเราเอง
React → ใช้ JWT ของเราเอง เรียก API= ผสม OAuth2 client + JWT custom (จากบทที่ 3)
19. Scenario C: Service-to-Service (Client Credentials)
OAuth ไม่ได้มีแค่สำหรับ user — เมื่อ service คุยกับ service (ไม่มี user เกี่ยวข้อง) ใช้ "client credentials" grant: service ใช้ client-id/secret แลก token เองเพื่อเรียก API อีกตัว ต่างจาก flow ที่ผ่านมาตรงที่ไม่มีหน้า login ของ user:
yaml
spring:
security:
oauth2:
client:
registration:
payment-service:
client-id: ${PAYMENT_CLIENT_ID}
client-secret: ${PAYMENT_CLIENT_SECRET}
authorization-grant-type: client_credentials
scope: payment.write
provider:
payment-service:
token-uri: https://auth.company.com/oauth2/tokenjava
// RestClient = HTTP client ตัวใหม่ของ Spring 6.1 สำหรับเรียก API ภายนอก (จะลงลึกในบทที่ 8)
// OAuth2AuthorizedClientManager = ตัวจัดการขอ/cache token ให้อัตโนมัติ
// Spring Boot 3.2+ auto-configure ให้แล้ว ถ้าใช้เวอร์ชันเก่ากว่าต้องประกาศ @Bean เอง
@Service
@RequiredArgsConstructor
public class PaymentClient {
// หมายเหตุ: restClient สร้างแบบ inline (ไม่ผ่าน Spring context)
// ใน production ควรประกาศเป็น @Bean แยก เพื่อตั้งค่า SSL / interceptor ได้
private final RestClient restClient = RestClient.builder()
.baseUrl("https://payment.company.com")
.build();
private final OAuth2AuthorizedClientManager clientManager;
public Payment createPayment(BigDecimal amount) {
OAuth2AuthorizeRequest req = OAuth2AuthorizeRequest
.withClientRegistrationId("payment-service")
.principal("system")
.build();
String token = clientManager.authorize(req).getAccessToken().getTokenValue();
return restClient.post()
.uri("/payments")
.header("Authorization", "Bearer " + token)
.body(new PaymentRequest(amount))
.retrieve()
.body(Payment.class);
}
}20. ⚠️ OAuth2 Pitfalls
| Pitfall | แก้ |
|---|---|
| เก็บ access token ใน localStorage | ❌ XSS risk (Cross-Site Scripting — โจรฝัง JS ในเว็บเรา แล้วอ่าน token ออกไป) — ใช้ HttpOnly cookie (cookie ที่ JS อ่านไม่ได้) หรือเก็บใน memory (JavaScript variable ที่อยู่ใน browser — หายตอน refresh หน้า) |
| ไม่ใช้ PKCE ใน SPA | ✅ ใช้ PKCE — มาตรฐานแล้ว |
| Access token อายุยาว | สั้น (15 นาที) + refresh token |
ไม่ verify aud claim | verify ว่า token มี audience (ผู้รับที่ตั้งใจ) = service ของเรา — ถ้าไม่ verify โจรเอา token ของ service อื่นมาใช้ได้ — ตั้งค่าใน Spring: spring.security.oauth2.resourceserver.jwt.audiences: my-service |
| HTTPS ไม่บังคับ | บังคับใน production — token หลุดได้ |
| ผูก scope กับ role 1-1 | scope = สิ่งที่ client ขอ (เช่น read:photos), role = สิ่งที่ user มี (เช่น ADMIN) — แยกกัน |
21. Checkpoint
🛠️ Checkpoint 7.1 — Login with Google
ทำ Spring Boot ที่:
- หน้า
/แสดง "Login with Google" - หลัง login → redirect ไป
/dashboard /meแสดง email + name + picture- ถ้า user ใหม่ → save ลง DB
🛠️ Checkpoint 7.2 — Resource Server
- รัน Keycloak ใน Docker:bash
docker run -p 8180:8080 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ quay.io/keycloak/keycloak:latest start-dev - เข้า http://localhost:8180 → สร้าง realm → สร้าง client (grant type: client_credentials) → สร้าง user
- ทำ Spring Boot Resource Server ที่ verify JWT จาก Keycloak
- ทดสอบด้วย (path เต็มของ token endpoint):bash
TOKEN=$(curl -s -X POST "http://localhost:8180/realms/myrealm/protocol/openid-connect/token" \ -d "grant_type=client_credentials&client_id=my-client&client_secret=my-secret" \ | jq -r .access_token) # ⚠️ grant_type=client_credentials เหมาะกับ local testing — ไม่ใช้ password grant ที่ deprecated แล้ว curl -H "Authorization: Bearer $TOKEN" localhost:8080/api/users/me
🛠️ Checkpoint 7.3 — Service to Service
ทำ 2 service ที่คุยกันด้วย client_credentials
22. สรุปบท
✅ OAuth2 = "อนุญาตให้ app เข้าถึง resource โดยไม่ต้องให้ password"
✅ 3 flow ที่ใช้จริง: Authorization Code (web), PKCE (SPA/mobile), Client Credentials (server) + 2 flow deprecated: Implicit, Password Grant
✅ OIDC = OAuth2 + Authentication — มี ID Token (JWT มี user info)
✅ "Login with X" → spring-boot-starter-oauth2-client + config provider
✅ Resource Server → spring-boot-starter-oauth2-resource-server + JWT issuer-uri
✅ Mapping JWT claims → Spring authorities ด้วย JwtAuthenticationConverter
✅ Hook OidcUserService เพื่อ save user ลง DB ครั้งแรก
✅ Production: HTTPS + short-lived access token + refresh token + HttpOnly cookie