Spring Security: JWT custom & Keycloak

Holy_DevHoly_Dev
12 min read

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ápCách hoạt độngƯu điểmNhược điểm
Session-basedSau khi login, server lưu thông tin phiên (session) → gắn sessionId trong cookieDễ triển khaiKhô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 requestStateless, không cần lưu phiênToken có thể bị lộ nếu lưu không an toàn
OAuth2 / OpenID ConnectDùng bên thứ 3 để xác thực (Google, Facebook, Github...)Chuẩn hóa, dễ mở rộngCần thêm công cụ hoặc dịch vụ ngoài
KeycloakLà một Identity Provider hỗ trợ OAuth2/OIDC/SAMLFull tính năng SSO, phân quyền, quản lý người dùngNặ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ốngPhù hợp dùng gì
Website nhỏ, ít người dùngSession-based, đơn giản
REST API, SPA (Vue/React), Mobile appJWT 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

ServiceMục tiêu bảo vệ
user-serviceLogin → trả về JWT
order-serviceYê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

  1. Gọi POST /api/auth/login với email + password → nhận JWT

     {
       "name": "John Doe",
       "email": "john@example.com",
       "password": "123456"
     }
    
  2. 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
           }'
    
  3. 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ẩu 123456

  • 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ằng jwt.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ự ánNhỏ, đơn giảnLớn, nhiều dịch vụ
Tốn công phát triểnCaoThấ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...).

1
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