Spring Security: JWT custom & Keycloak

Video: https://youtu.be/qQd73QAmyrE
1. Mục đích của Authentication là gì?
Authentication = Xác thực → Xác định người dùng là ai
Một request đến server: Là của ai? Được phép làm gì?
Server cần có cách để kiểm tra danh tính của người gửi request, ví dụ:
Có phải là
userA
đã đăng nhập không?Có đúng là
admin
không?
2. Các phương pháp xác thực:
Phương pháp | Cách hoạt động | Ưu điểm | Nhược điểm |
Session-based | Sau khi login, server lưu thông tin phiên (session) → gắn sessionId trong cookie | Dễ triển khai | Không phù hợp cho microservices, khó scale |
JWT (JSON Web Token) | Sau khi login, server trả về một token chứa thông tin mã hóa (userId, role,...) → client gửi token trong mỗi request | Stateless, không cần lưu phiên | Token có thể bị lộ nếu lưu không an toàn |
OAuth2 / OpenID Connect | Dùng bên thứ 3 để xác thực (Google, Facebook, Github...) | Chuẩn hóa, dễ mở rộng | Cần thêm công cụ hoặc dịch vụ ngoài |
Keycloak | Là một Identity Provider hỗ trợ OAuth2/OIDC/SAML | Full tính năng SSO, phân quyền, quản lý người dùng | Nặng, phải học thêm, khó triển khai nếu nhỏ |
3. JWT là gì? Dùng để làm gì?
JWT là một mã thông báo (token) dạng text, ví dụ:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Trong JWT sẽ chứa thông tin về người dùng như:
{
"sub": "user123",
"role": "admin",
"exp": 1723456000
}
Dùng JWT khi nào?
Khi bạn không muốn lưu phiên đăng nhập (stateless)
Khi bạn làm API, đặc biệt là microservices
Khi bạn cần truyền thông tin người dùng mà không cần gọi lại user-service
4. Luồng JWT cơ bản:
👇Login (Nhận token)
POST /api/login
→ Server xác thực username/password
→ Trả về JWT chứa thông tin user
👇 Gửi request sau đó
GET /api/orders
Authorization: Bearer <JWT>
→ Server đọc token → xác định userId
→ Server không cần lưu trạng thái gì — tất cả chứa trong JWT
5. Vậy Keycloak là gì?
Keycloak = Một dịch vụ chuyên để xác thực người dùng
Hỗ trợ login, đăng ký, đổi mật khẩu, quên mật khẩu...
Hỗ trợ OAuth2, OpenID Connect, SAML
Có UI để quản lý user, role, permission
Có thể kết nối Google, Facebook, LDAP...
👉 Nó giống như một Auth Center — bạn không cần tự viết login/register nữa.
6. Vậy làm hệ thống lớn thì dùng cái nào?
Kiến trúc hệ thống | Phù hợp dùng gì |
Website nhỏ, ít người dùng | Session-based, đơn giản |
REST API, SPA (Vue/React), Mobile app | JWT hoặc OAuth2 |
Hệ thống lớn, đa dịch vụ (microservices) | Keycloak, OAuth2, OpenID Connect |
Dự án cần Single Sign-On (SSO) | Keycloak, Auth0, Okta |
👉 Chọn cái nào phụ thuộc vào:
Nhu cầu chức năng (đăng nhập, phân quyền, SSO…)
Kiến trúc (monolith hay microservices)
Kinh phí (miễn phí hay cần giải pháp thương mại)
Đội ngũ (có đủ người hiểu không)
Tóm tắt:
JWT = Một cách để xác thực người dùng kiểu “tự mang chứng minh thư đi khắp nơi”
Keycloak = Một dịch vụ chuyên lo chuyện “đăng nhập, xác thực, cấp token”
Nhiều phương pháp là do nhiều hoàn cảnh sử dụng khác nhau
Mục tiêu : Spring Security + JWT
Service | Mục tiêu bảo vệ |
user-service | Login → trả về JWT |
order-service | Yêu cầu JWT trong header để tạo đơn hàng |
Các bước thực hiện
1. Thêm thư viện JWT vào user-service
& order-service
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' // or gson
2. Viết JwtService
để tạo và xác thực token (user-service)
import com.example.userservice.model.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Service;
import java.util.Date;
@Service
public class JwtService {
private static final String SECRET_KEY = "my-very-long-and-secure-jwt-secret-key-123456";
public String generateToken(User user) {
return Jwts.builder()
.setSubject(user.getEmail())
.claim("userId", user.getId())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 86400000)) // 1 ngày
.signWith(Keys.hmacShaKeyFor(SECRET_KEY.getBytes()), SignatureAlgorithm.HS256)
.compact();
}
public Claims extractClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(SECRET_KEY.getBytes())
.build()
.parseClaimsJws(token)
.getBody();
}
public boolean isTokenValid(String token) {
return extractClaims(token).getExpiration().after(new Date());
}
public Long extractUserId(String token) {
return extractClaims(token).get("userId", Long.class);
}
}
3. Tạo AuthController
để login/register (user-service)
import com.example.userservice.model.User;
import com.example.userservice.repository.UserRepository;
import com.example.userservice.services.JwtService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class AuthController {
private final UserRepository userRepo;
private final JwtService jwtService;
private final BCryptPasswordEncoder passwordEncoder;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody Map<String, String> body) {
String email = body.get("email");
String password = body.get("password");
User user = userRepo.findByEmail(email)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials"));
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials");
}
String token = jwtService.generateToken(user);
return ResponseEntity.ok(Map.of("token", token));
}
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody Map<String, String> body) {
String email = body.get("email");
String password = body.get("password");
String name = body.get("name");
if (userRepo.findByEmail(email).isPresent()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email already in use");
}
User newUser = new User();
newUser.setEmail(email);
newUser.setPassword(passwordEncoder.encode(password));
newUser.setName(name);
userRepo.save(newUser);
String token = jwtService.generateToken(newUser);
return ResponseEntity.ok(Map.of("token", token));
}
}
4. Cấu hình bảo mật với Spring Security (user-service):
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.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
// Định nghĩa cấu hình bảo mật
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable) // Tắt CSRF
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // Cho phép truy cập không cần đăng nhập với /api/auth/**
.anyRequest().authenticated() // Các endpoint khác yêu cầu xác thực
)
.build();
}
// Bean mã hóa mật khẩu bằng BCrypt
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Ghi chú: Ở bước này, chúng ta thiết lập Spring Security để phân biệt các API công khai (auth) và các API riêng tư (order, user, v.v.).
5. Bảo vệ API bằng Spring Security (order-service)
SecurityConfig.java
import com.example.orderservice.middleware.JwtAuthFilter;
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.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/orders/**").authenticated()
.anyRequest().permitAll())
.addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public JwtAuthFilter jwtAuthFilter() {
return new JwtAuthFilter();
}
}
6. Middleware: JwtAuthFilter.java
(order-service)
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import java.io.IOException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.util.List;
public class JwtAuthFilter extends OncePerRequestFilter {
private static final String SECRET_KEY = "my-very-long-and-secure-jwt-secret-key-123456";
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
Claims claims = Jwts.parserBuilder()
.setSigningKey(SECRET_KEY.getBytes())
.build()
.parseClaimsJws(token)
.getBody();
Long userId = claims.get("userId", Long.class);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(userId, null, List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
filterChain.doFilter(request, response);
}
}
7. Trong OrderController
, lấy userId
từ SecurityContext
@PostMapping
public Order placeOrder(@RequestBody Order order) {
Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
order.setUserId(userId);
return orderService.createOrder(order);
}
Lưu ý trong api-gateway
:
server:
port: 8080
spring:
application:
name: api-gateway
cloud:
gateway:
routes:
- id: user-service
uri: lb://user-service
predicates:
- Path=/api/users/**
- id: user-service-auth
uri: lb://user-service
predicates:
- Path=/api/auth/**
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/orders/**
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
Cách test
Gọi
POST /api/auth/login
với email + password → nhận JWT{ "name": "John Doe", "email": "john@example.com", "password": "123456" }
Gửi
POST /api/orders
r:curl -X POST http://localhost:8080/api/orders \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_JWT_TOKEN_HERE" \ -d '{ "product": "Macbook Pro", "price": 2500.0, "total": 2500.0 }'
Nếu không có JWT → trả 403 Forbidden
Tổng kết
Spring Security bảo vệ API
JWT xác thực người dùng và truyền userId cho order-service
Không cần gọi
user-service
để xác minh – tự contain trong token
Keycloak
Mục tiêu:
Cấu hình Spring Security để tích hợp với Keycloak
Chỉ cho phép người dùng đã đăng nhập thông qua Keycloak được gọi API
/api/orders
Các bước chuẩn bị:
1. Chạy Keycloak (dùng docker)
services:
keycloak:
image: quay.io/keycloak/keycloak:24.0.2
container_name: keycloak
command: start-dev
ports:
- "8080:8080"
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
2. Cấu hình realm, client và user:
Tạo Realm:
demo-realm
Tạo Client:
order-service
(Client Type: OpenID Connect →confidential
)Client authentication: ON
Standard Flow Enabled: ON
Tạo User:
user1
, mật khẩu123456
Gán role nếu cần (vd:
user
,admin
)
Import file hoàn chỉnh:
{
"realm": "demo-realm",
"enabled": true,
"clients": [
{
"clientId": "user-service",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"secret": "user-service-secret",
"redirectUris": ["*"],
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true
}
],
"users": [
{
"username": "user1",
"email": "holyne@gmail.com",
"enabled": true,
"emailVerified": true,
"credentials": [
{
"type": "password",
"value": "123456",
"temporary": false
}
]
}
]
}
Spring Boot tích hợp với Keycloak
1. Thêm dependency:
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
2. application.yml
:
spring:
security:
oauth2:
resource-server:
jwt:
issuer-uri: http://localhost:8080/realms/demo-realm
Spring Boot sẽ tự động lấy public key của Keycloak từ
.well-known/openid-configuration
Cấu hình Security:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/orders").authenticated()
.anyRequest().permitAll()
)
.oauth2ResourceServer(resource -> resource
.jwt(Customizer.withDefaults())
)
.build();
}
Tạo FeignClientInterceptorConfig:
package com.example.orderservice.config;
import feign.RequestInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
@Configuration
public class FeignClientInterceptorConfig {
@Bean
public RequestInterceptor requestInterceptor() {
return requestTemplate -> {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof JwtAuthenticationToken jwtAuth) {
String token = jwtAuth.getToken().getTokenValue();
requestTemplate.header("Authorization", "Bearer " + token);
}
};
}
}
Tại UserClient:
@FeignClient(name = "user-service", configuration = FeignClientInterceptorConfig.class)
public interface UserClient {
@GetMapping("/api/users/keycloak/{sub}")
UserDto getUserByKeycloakId(@PathVariable("sub") String keycloakId);
}
Update Controller :
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@PostMapping
public Order placeOrder(@RequestBody Order order, JwtAuthenticationToken auth) {
String sub = auth.getToken().getSubject(); // lấy Keycloak-sub (UUID)
UserDto user = userClient.getUserByKeycloakId(sub); // Feign gọi user-service
order.setUserId(user.getId()); // gán Long userId
return orderService.createOrder(order);
}
}
Bên user-service
:
Thêm trường này vào User:
private String keycloakId;
Nhắc nhở: các bạn nhớ thêm openfeign dependency trong build.gradle của user-service nhé, copy từ bên order-service qua là được.
UserService: Tự động import user từ keycloak vào DB
public User ensureUserExistsFromToken(Jwt jwt) {
String keycloakId = jwt.getSubject(); // sub
return userRepository.findByKeycloakId(keycloakId)
.orElseGet(() -> {
// Tạo mới user nếu chưa có
User user = new User();
user.setKeycloakId(keycloakId);
user.setEmail(jwt.getClaim("email"));
user.setName(jwt.getClaim("preferred_username"));
return userRepository.save(user);
});
}
Gọi trong controller:
@GetMapping("/keycloak/{sub}")
public UserDto getUserByKeycloakId(@PathVariable String sub, @AuthenticationPrincipal Jwt jwt) {
//tự tạo user từ token
User user = userRepository.findByKeycloakId(sub)
.orElseGet(() -> userService.ensureUserExistsFromToken(jwt));
return new UserDto(user.getId(), user.getName(), user.getEmail());
}
Note: trong user repo nhớ thêm
Optional<User> findByKeycloakId(String keycloakId);
Gửi đơn hàng:
Đăng nhập để lấy token từ Keycloak:
curl -X POST http://localhost:8080/realms/demo-realm/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=user-service" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "username=user1" \
-d "password=123456" \
-d "grant_type=password"
Dùng token đó để tạo đơn hàng:
curl -X POST http://localhost:8080/api/orders \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"product": "Macbook Pro",
"price": 2500.0,
"total": 2500.0
}'
❌ Nếu không có token → 401 Unauthorized
❌ Nếu token không hợp lệ → 403 Forbidden
Tổng thể luồng hoạt động
[Bước 1] Client đăng nhập Keycloak
⇨ Gửi username/password đến Keycloak
⇨ Nhận lại JWT (Access Token)
[Bước 2] Client gọi API đặt hàng đến Order-Service
⇨ Gửi POST /api/orders
⇨ Header: Authorization: Bearer <JWT>
[Bước 3] Order-Service xác thực token
⇨ Spring Security tự động verify JWT bằng public key từ Keycloak
⇨ Nếu hợp lệ, lưu JwtAuthenticationToken vào SecurityContext
[Bước 4] Order-Service lấy `sub` từ token
⇨ Dùng `auth.getToken().getSubject()` → sub (UUID của user trên Keycloak)
[Bước 5] Gọi sang User-Service bằng Feign Client
⇨ GET /api/users/keycloak/{sub}
⇨ Feign interceptor tự động gắn lại JWT vào header
[Bước 6] User-Service xác thực token từ header
⇨ Spring Security trong user-service cũng verify JWT
⇨ Nếu hợp lệ, giải mã ra claims: sub, email, preferred_username,...
[Bước 7] User-Service kiểm tra DB:
⇨ Nếu tồn tại user có keycloak_id = sub ⇒ trả về user
⇨ Nếu chưa có:
⇨ Tạo user mới từ thông tin JWT (sub, email, preferred_username)
⇨ Lưu vào DB
⇨ Trả về user
[Bước 8] Order-Service nhận được UserDto
⇨ order.setUserId(user.getId())
⇨ Gọi orderRepository.save(order)
[Bước 9] Trả về response tạo đơn hàng thành công cho Client
Tổng kết
Spring Security tích hợp OAuth2 Resource Server → nhận và kiểm tra token từ Keycloak
Không cần tự viết middleware decode JWT nữa
Keycloak đóng vai trò xác thực trung tâm, có thể dùng chung cho nhiều service
Token chứa sẵn
userId
, bạn lấy bằngjwt.getSubject()
Sơ đồ trình tự (sequence diagram): So sánh JWT tự tạo vs Keycloak
A. JWT tự tạo (tự viết logic đăng nhập và sinh token)
sequenceDiagram
participant User
participant AuthService
participant JWTUtil
participant OrderService
User->>AuthService: POST /login (username, password)
AuthService->>AuthService: Validate credentials
alt Credentials valid
AuthService->>JWTUtil: Create JWT (userId, roles,...)
JWTUtil-->>AuthService: JWT token
AuthService-->>User: 200 OK + JWT token
else Invalid credentials
AuthService-->>User: 401 Unauthorized
end
Note over User,OrderService: Later, call protected API
User->>OrderService: POST /api/orders with Authorization: Bearer <JWT>
OrderService->>OrderService: Spring Security filters JWT
OrderService->>JWTUtil: Parse & validate JWT
JWTUtil-->>OrderService: userId, roles
alt Valid token
OrderService-->>User: 200 OK (Order Created)
else Invalid or missing token
OrderService-->>User: 403 Forbidden
end
B. Dùng Keycloak (bên thứ 3 xác thực)
sequenceDiagram
participant User
participant Keycloak
participant OrderService
participant UserService
participant UserRepo
User->>Keycloak: Đăng nhập (username/password)
Keycloak-->>User: Trả về Access Token (JWT)
User->>OrderService: Gọi API (Authorization: Bearer <token>)
OrderService->>OrderService: Trích token từ SecurityContextHolder
OrderService->>FeignClient: Gọi UserService (truyền token vào header)
FeignClient->>UserService: Gọi API /keycloak/{sub} (Authorization: Bearer <token>)
UserService->>UserService: Lấy Jwt từ @AuthenticationPrincipal
UserService->>UserRepo: Tìm user theo keycloakId (sub)
alt Nếu có user
UserRepo-->>UserService: Trả về user
else Nếu không có user
UserService->>UserRepo: Tạo mới user từ token (email, name)
UserRepo-->>UserService: Trả về user vừa tạo
end
UserService-->>FeignClient: Trả về UserDto
FeignClient-->>OrderService: Trả về UserDto
OrderService-->>User: Trả về kết quả cuối cùng
2. So sánh nhanh JWT tự tạo
vs Keycloak
Tiêu chí | JWT tự tạo (custom) | Keycloak (chuẩn OIDC) |
Tự viết xác thực | ✔️ Có | ❌ Không cần |
Sinh token thủ công | ✔️ | ❌ Keycloak sinh |
Quản lý người dùng | ❌ Bạn phải tự làm | ✔️ UI quản lý sẵn |
Phân quyền, role | ❌ Bạn tự code | ✔️ Có sẵn |
SSO / OAuth2 / OpenID Connect | ❌ Không hỗ trợ | ✔️ Chuẩn quốc tế |
Phù hợp dự án | Nhỏ, đơn giản | Lớn, nhiều dịch vụ |
Tốn công phát triển | Cao | Thấp hơn (nhưng phải học Keycloak) |
Kết luận dành cho bạn:
Nếu bạn đang tự học, làm pet project, hoặc backend đơn giản → dùng JWT custom là OK.
Nếu bạn muốn hệ thống phân quyền rõ ràng, mở rộng về sau, có thể SSO, hoặc microservices → nên dùng Keycloak hoặc tương tự (Auth0, Okta...).
Subscribe to my newsletter
Read articles from Holy_Dev directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Holy_Dev
Holy_Dev
strong desire for learning new things