spring security với jwt
Tung Bui Thanh (TungDaDev)11 mins read

Bài này mình tích hợp Spring Security với JWT để bảo vệ API. Đi từng bước, từ cấu hình ban đầu, tạo cặp khóa, tới viết service và controller cho đăng ký, đăng nhập.
# cấu hình
Vào Spring Initializr, tạo một dự án dùng Java 21, Maven, Jar và các dependency sau:
- Spring Data JPA
- Spring Web
- Lombok
- Spring Security
- PostgreSQL Driver
- OAuth2 Resource Server
# thiết lập cơ sở dữ liệu postgreSQL
Mình dùng Docker Compose để chạy PostgreSQL. Tạo file docker-compose.yaml ở thư mục gốc của dự án.
services:
postgre:
image: postgres:18
ports:
- '5432:5432'
environment:
- POSTGRES_DB=database
- POSTGRES_USER=admin
- POSTGRES_PASSWORD=admin
volumes:
- postgres_data:/var/lib/postgresql
volumes:
postgres_data:Chạy docker compose up -d để khởi động container.
Để ý chỗ mount volume: từ PostgreSQL 18, image chính thức lưu data ở /var/lib/postgresql/18/docker và khuyên mount cả thư mục /var/lib/postgresql. Nếu vẫn mount kiểu cũ vào /var/lib/postgresql/data thì container 18 sẽ báo lỗi rồi thoát ngay. Cũng vì vậy mà nên ghi rõ major version (postgres:18) thay vì latest: hôm nào latest nhảy lên bản mới, data cũ sẽ không tự nâng cấp theo.
Thêm file application.properties để cấu hình cho ứng dụng Spring Boot:
# Database Configuration
spring.datasource.url=jdbc:postgresql://localhost:5432/database
spring.datasource.username=admin
spring.datasource.password=admin
spring.datasource.driver-class-name=org.postgresql.Driver
# JPA Configuration
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.show-sql=true
# Server Configuration
server.port=8080Trong đó:
- spring.datasource.url: URL kết nối tới PostgreSQL.
- spring.datasource.username: tên người dùng PostgreSQL.
- spring.datasource.password: mật khẩu truy cập database.
- spring.jpa.hibernate.ddl-auto: tự động cập nhật schema database.
updatetiện khi học và chạy local, còn production thì nên quản lý schema bằng Flyway hoặc Liquibase. - spring.jpa.properties.hibernate.dialect: từ Hibernate 6, dialect được tự nhận diện từ kết nối nên dòng này có thể bỏ (để lại thì Hibernate chỉ log một cảnh báo).
- spring.jpa.show-sql: in câu SQL ra console để debug.
- server.port: cổng chạy ứng dụng (mặc định là 8080).
Database, user và mật khẩu ở đây khớp với file docker-compose.yaml bên trên. Chạy thật thì đổi sang thông tin của bạn, và đừng để mật khẩu thật nằm trong file commit lên repo (dùng biến môi trường như SPRING_DATASOURCE_PASSWORD).
# tạo khóa private và public cho jwt
Mình ký JWT bằng RSA (thuật toán RS256): server giữ khóa private để ký token, còn ai có khóa public đều kiểm tra được chữ ký nhưng không tự tạo token giả được. Cách này hợp khi về sau có thêm service khác cần xác minh token: chỉ việc đưa họ khóa public. Nếu chỉ một ứng dụng vừa ký vừa kiểm thì một khóa bí mật dùng chung (HS256) cũng được, nhưng lộ khóa đó là lộ luôn quyền ký.
Các bước tạo khóa private và public cho JWT:
Tạo khóa Private:
Chạy lệnh sau trong terminal để tạo khóa private trong thư mục resources:
openssl genpkey -algorithm RSA -out src/main/resources/private.pemTạo khóa Public (từ khóa Private):
openssl rsa -pubout -in src/main/resources/private.pem -out src/main/resources/public.pemLưu ý:
- Không bao giờ commit
private.pemlên GitHub. Khóa public thì không bí mật, nhưng để chung chỗ với khóa private cho dễ quản lý cũng được. - Lệnh
genpkeyở trên mặc định tạo khóa RSA 2048 bit, định dạng PKCS#8 (BEGIN PRIVATE KEY), đúng định dạng Spring Security đọc được. - Để khóa trong
src/main/resourceschỉ hợp cho lúc học: nó sẽ bị đóng gói vào file jar. Chạy thật thì đặt file ở ngoài và trỏ tới bằngfile:/đường/dẫn/private.pem, hoặc lấy từ secret manager.
Update application.properties để thêm các key vào cấu hình:
jwt.private.key=classpath:private.pem
jwt.public.key=classpath:public.pem# code thôi nào
Tạo class SecurityConfig.java trong thư mục configs với nội dung sau:
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.web.SecurityFilterChain;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Value("${jwt.public.key}")
private RSAPublicKey publicKey;
@Value("${jwt.private.key}")
private RSAPrivateKey privateKey;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.requestMatchers(HttpMethod.POST, "/signin").permitAll()
.requestMatchers(HttpMethod.POST, "/login").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(config -> config.jwt(jwt -> jwt.decoder(jwtDecoder())));
return http.build();
}
@Bean
BCryptPasswordEncoder bPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
JwtEncoder jwtEncoder() {
var jwk = new RSAKey.Builder(this.publicKey).privateKey(this.privateKey).build();
var jwks = new ImmutableJWKSet<>(new JWKSet(jwk));
return new NimbusJwtEncoder(jwks);
}
@Bean
JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(publicKey).build();
}
}Trong đó:
@Configuration: đánh dấu đây là class cấu hình của Spring.
securityFilterChain: cấu hình các quy tắc bảo mật, tắt CSRF (vì dùng JWT stateless) và yêu cầu xác thực cho các endpoint còn lại.
SessionCreationPolicy.STATELESS: ứng dụng không tạo hay dùng session HTTP (stateless với JWT).
@EnableWebSecurity: bật cấu hình bảo mật của Spring Security cho ứng dụng web, gồm thiết lập các filter, bảo vệ endpoint và áp dụng các quy tắc bảo mật.
@EnableMethodSecurity: annotation của Spring Security cho phép bảo mật ở cấp method, qua các annotation như @PreAuthorize, @PostAuthorize, @Secured và @RolesAllowed.
privateKey và publicKey: cặp khóa RSA dùng để ký và xác minh JWT. Annotation @Value inject các khóa này từ
application.propertiesvào các field. Property chỉ là chuỗiclasspath:private.pem, việc đọc file PEM và chuyển thànhRSAPrivateKey/RSAPublicKeylà do converter mà Spring Security đăng ký sẵn (RsaKeyConversionServicePostProcessor) làm giúp.CSRF: tắt bảo vệ CSRF (Cross-Site Request Forgery), vì API REST stateless dùng JWT thường không cần. Lý do: CSRF lợi dụng việc trình duyệt tự gửi kèm cookie, còn token ở đây nằm trong header
Authorizationdo chính client gắn vào. Nếu bạn lưu JWT trong cookie thì chuyện lại khác, phải bật CSRF lại.authorizeHttpRequests: cấu hình quy tắc phân quyền theo URL.
requestMatchers(HttpMethod.POST, "/signin").permitAll(): cho phép truy cập không cần xác thực tới /signin (đăng ký) và /login, tức ai cũng gọi được mà không cần đăng nhập.
anyRequest().authenticated(): mọi request còn lại đều phải xác thực.
oauth2ResourceServer: cấu hình ứng dụng thành một OAuth 2.0 resource server dùng JWT để xác thực.
config.jwt(jwt -> jwt.decoder(jwtDecoder())): chỉ định bean
jwtDecoderđể giải mã và xác thực token JWT.BCryptPasswordEncoder: bean mã hóa mật khẩu bằng thuật toán băm BCrypt. BCrypt được dùng nhiều để lưu mật khẩu vì có thể điều chỉnh độ nặng của thuật toán, giúp chống brute-force.
JwtEncoder: bean chịu trách nhiệm mã hóa (ký) token JWT.
RSAKey.Builder: tạo khóa RSA từ cặp khóa public và private đã có.
ImmutableJWKSet<>(new JWKSet(jwk)): bọc khóa RSA trong một JSON Web Key Set (JWKSet) bất biến.NimbusJwtEncoder(jwks): dùng thư viện Nimbus để tạo JWT encoder, ký token bằng khóa RSA private.
JwtDecoder: bean chịu trách nhiệm giải mã (xác minh) token JWT.
- NimbusJwtDecoder.withPublicKey(publicKey).build(): tạo JWT decoder dùng khóa RSA public để xác minh chữ ký của token. Mặc định decoder còn kiểm tra
expvànbf(cho lệch đồng hồ 60 giây), token hết hạn sẽ bị trả 401.
- NimbusJwtDecoder.withPublicKey(publicKey).build(): tạo JWT decoder dùng khóa RSA public để xác minh chữ ký của token. Mặc định decoder còn kiểm tra
Class ClientEntity lưu trữ thông tin user:
import org.springframework.security.crypto.password.PasswordEncoder;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "tb_clients")
@Getter
@Setter
@NoArgsConstructor
public class ClientEntity {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
@Column(name = "client_id")
private Long clientId;
private String name;
@Column(unique = true)
private String cpf;
@Column(unique = true)
private String email;
@JsonIgnore
private String password;
@Column(name = "user_type")
private String userType = "client";
public Boolean isLoginCorrect(String password, PasswordEncoder passwordEncoder) {
return passwordEncoder.matches(password, this.password);
}
}cpf là mã số định danh cá nhân ở Brazil (code gốc lấy từ một bài hướng dẫn của dev Brazil), bạn đổi thành số CCCD hay username tuỳ hệ thống. @JsonIgnore trên password để khi controller trả entity về, chuỗi hash mật khẩu không lọt ra response. Làm kỹ hơn thì trả về một DTO riêng thay vì trả thẳng entity.
Class ClientRepository làm việc trực tiếp với database:
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import com.tungdadev.spring_jwt.entities.ClientEntity;
@Repository
public interface ClientRepository extends JpaRepository<ClientEntity, Long> {
Optional<ClientEntity> findByEmail(String email);
Optional<ClientEntity> findByCpf(String cpf);
Optional<ClientEntity> findByEmailOrCpf(String email, String cpf);
}Các Service để xử lý logic:
- Client service:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import com.tungdadev.spring_jwt.entities.ClientEntity;
import com.tungdadev.spring_jwt.repositories.ClientRepository;
@Service
public class ClientService {
@Autowired
private ClientRepository clientRepository;
@Autowired
private BCryptPasswordEncoder bPasswordEncoder;
public ClientEntity createClient(String name, String cpf, String email, String password) {
var clientExists = this.clientRepository.findByEmailOrCpf(email, cpf);
if (clientExists.isPresent()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email/Cpf already exists.");
}
var newClient = new ClientEntity();
newClient.setName(name);
newClient.setCpf(cpf);
newClient.setEmail(email);
newClient.setPassword(bPasswordEncoder.encode(password));
return clientRepository.save(newClient);
}
}- Token service:
import java.time.Instant;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;
import com.tungdadev.spring_jwt.repositories.ClientRepository;
@Service
public class TokenService {
@Autowired
private ClientRepository clientRepository;
@Autowired
private JwtEncoder jwtEncoder;
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
public String login(String email, String password) {
var client = this.clientRepository.findByEmail(email)
.orElseThrow(() -> new BadCredentialsException("Email/password invalid"));
var isCorrect = client.isLoginCorrect(password, bCryptPasswordEncoder);
if (!isCorrect) {
throw new BadCredentialsException("Email/password invalid");
}
var now = Instant.now();
var expiresIn = 300L;
var claims = JwtClaimsSet.builder()
.issuer("pic_pay_backend")
.subject(client.getEmail())
.issuedAt(now)
.expiresAt(now.plusSeconds(expiresIn))
.claim("scope", client.getUserType())
.build();
var jwtValue = jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
return jwtValue;
}
}Sai email hay sai mật khẩu đều trả cùng một lỗi BadCredentialsException. Nếu báo riêng "Email not found" thì kẻ tấn công thử vài lần là biết email nào có tài khoản trong hệ thống.
Token chỉ sống 300 giây (5 phút). Vì JWT là stateless, server không lưu gì nên cũng không "thu hồi" được một token đã phát ra: ai cầm token thì dùng được tới lúc hết hạn. Đó là lý do người ta để access token ngắn hạn, kèm một refresh token sống lâu hơn (lưu phía server để còn thu hồi được) dùng để xin access token mới. Bài này dừng ở access token cho gọn. Claim scope mang giá trị client sẽ được JwtAuthenticationConverter mặc định đổi thành authority SCOPE_client, chỗ này dùng lại ở controller bên dưới.
Cuối cùng là Controller, nơi nhận request. Hai DTO dùng trong controller chỉ là record đơn giản (đặt trong package controllers.dto):
public record NewClientDTO(String name, String cpf, String email, String password) {}
public record LoginDTO(String email, String password) {}- Client controller:
package com.tungdadev.spring_jwt.controllers;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import com.tungdadev.spring_jwt.controllers.dto.NewClientDTO;
import com.tungdadev.spring_jwt.entities.ClientEntity;
import com.tungdadev.spring_jwt.services.ClientService;
@RestController
public class ClientController {
@Autowired
private ClientService clientService;
@PostMapping("/signin")
public ResponseEntity<ClientEntity> createNewClient(@RequestBody NewClientDTO client) {
var newClient = this.clientService.createClient(client.name(), client.cpf(), client.email(), client.password());
return ResponseEntity.status(HttpStatus.CREATED).body(newClient);
}
@GetMapping("/protectedRoute")
@PreAuthorize("hasAuthority('SCOPE_client')")
public ResponseEntity<String> protectedRoute(JwtAuthenticationToken token) {
return ResponseEntity.ok("Authorized");
}
}Trong đó:
/protectedRoute: route riêng tư, chỉ truy cập được khi có JWT sau khi đăng nhập.
Token phải đặt trong header dưới dạng Bearer token, ví dụ:
Authorization: Bearer <your-jwt-token>Token cũng có thể dùng ở các service trong ứng dụng: bạn lấy thông tin từ token để thực hiện các hành động bảo mật hoặc truy vấn dữ liệu.
@PreAuthorize: annotation
@PreAuthorizecủa Spring Security kiểm tra phân quyền trước khi method được gọi. Thường đặt ở method của các component Spring (như controller hoặc service) để giới hạn quyền truy cập theo vai trò, quyền hạn của người dùng hoặc các điều kiện bảo mật khác.- Annotation này khai báo điều kiện mà method phải thỏa trước khi chạy. Điều kiện true thì method được thực thi, false thì bị từ chối truy cập.
"hasAuthority('SCOPE_client')": kiểm tra user hoặc client đang xác thực có quyền SCOPE_client hay không. Có thì
protectedRoute()được chạy, không thì bị từ chối.Token Controller: nơi bạn đăng nhập, thành công thì nhận về một token. Token này dùng để xác thực các request tới những route được bảo vệ.
package com.tungdadev.spring_jwt.controllers;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import com.tungdadev.spring_jwt.controllers.dto.LoginDTO;
import com.tungdadev.spring_jwt.services.TokenService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@RestController
public class TokenController {
@Autowired
private TokenService tokenService;
@PostMapping("/login")
public ResponseEntity<Map<String, String>> login(@RequestBody LoginDTO loginDTO) {
var token = this.tokenService.login(loginDTO.email(), loginDTO.password());
return ResponseEntity.ok(Map.of("token", token));
}
}# lời kết
Tới đây là bạn đã có một API được bảo vệ bằng Spring Security và JWT: cặp khóa RSA, cấu hình SecurityConfig, đăng ký, đăng nhập và một route cần token để truy cập.
Bảo mật không dừng ở cấu hình ban đầu. Cần rà soát lại thường xuyên khi có thêm tính năng, và nhớ các lưu ý ở trên, nhất là chuyện không commit khóa private lên repo.
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.
Reference: