Back to blog

Bảo Mật Spring Boot: Xác Thực JWT & Phân Quyền

javaspring-bootsecurityjwtauthenticationbackend
Bảo Mật Spring Boot: Xác Thực JWT & Phân Quyền

Giới Thiệu

Bảo mật là một khía cạnh quan trọng của bất kỳ ứng dụng web nào. Spring Security cung cấp hỗ trợ xác thực và phân quyền toàn diện cho các ứng dụng Spring Boot, bảo vệ REST API của bạn khỏi truy cập trái phép.

Trong hướng dẫn toàn diện này, chúng ta sẽ tìm hiểu:

  • Hiểu kiến trúc Spring Security
  • Triển khai xác thực dựa trên JWT
  • Mã hóa mật khẩu với BCrypt
  • Phân quyền dựa trên vai trò (RBAC)
  • Bảo mật các endpoint REST API
  • Xử lý ngoại lệ xác thực
  • Các phương pháp hay nhất cho bảo mật production

Yêu cầu: Bài hướng dẫn này xây dựng dựa trên Tích Hợp Database Spring Boot. Đảm bảo bạn đã có ứng dụng Spring Boot hoạt động với JPA trước khi tiếp tục.

Tại Sao Nên Dùng Spring Security?

Spring Security cung cấp:

  • Bảo mật tiêu chuẩn công nghiệp: Framework đã được kiểm chứng bởi hàng triệu người
  • Xác thực linh hoạt: Hỗ trợ nhiều cơ chế xác thực khác nhau
  • Phân quyền chi tiết: Kiểm soát truy cập ở cấp độ method và URL
  • Bảo vệ CSRF: Tích hợp sẵn bảo vệ Cross-Site Request Forgery
  • Quản lý session: Xử lý JWT stateless hoặc session stateful
  • Tích hợp: Tích hợp liền mạch với hệ sinh thái Spring

Hiểu Authentication vs Authorization

Authentication (Xác thực): Xác minh bạn là ai (đăng nhập bằng username/password)

Authorization (Phân quyền): Xác minh bạn có thể truy cập gì (vai trò và quyền của người dùng)

Luồng hoạt động:

  1. Người dùng cung cấp thông tin đăng nhập (username/password)
  2. Ứng dụng xác minh thông tin đăng nhập (authentication)
  3. Ứng dụng cấp JWT token
  4. Client gửi token với mỗi request
  5. Ứng dụng xác minh token và kiểm tra quyền (authorization)

Thiết Lập Dependencies

Cập nhật pom.xml:

<dependencies>
    <!-- Spring Security -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
 
    <!-- JWT Support -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.3</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.12.3</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.12.3</version>
        <scope>runtime</scope>
    </dependency>
 
    <!-- Các dependencies hiện có -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

Đối với Gradle:

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}

Tạo Role và User Entities

Role Entity

src/main/java/com/example/demo/entity/Role.java:

package com.example.demo.entity;
 
import jakarta.persistence.*;
 
@Entity
@Table(name = "roles")
public class Role {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(unique = true, nullable = false)
    private String name;
 
    private String description;
 
    // Constructors
    public Role() {}
 
    public Role(String name) {
        this.name = name;
    }
 
    // Getters và Setters
    public Long getId() {
        return id;
    }
 
    public void setId(Long id) {
        this.id = id;
    }
 
    public String getName() {
        return name;
    }
 
    public void setName(String name) {
        this.name = name;
    }
 
    public String getDescription() {
        return description;
    }
 
    public void setDescription(String description) {
        this.description = description;
    }
}

Cập Nhật User Entity

Cập nhật src/main/java/com/example/demo/entity/User.java để bao gồm roles:

@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
    name = "user_roles",
    joinColumns = @JoinColumn(name = "user_id"),
    inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();

Triển Khai UserDetailsService

Spring Security sử dụng UserDetailsService để tải dữ liệu cụ thể của người dùng.

src/main/java/com/example/demo/security/CustomUserDetailsService.java:

package com.example.demo.security;
 
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
import java.util.Set;
import java.util.stream.Collectors;
 
@Service
public class CustomUserDetailsService implements UserDetailsService {
 
    private final UserRepository userRepository;
 
    @Autowired
    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
 
    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("Không tìm thấy người dùng: " + username));
 
        if (!user.getActive()) {
            throw new UsernameNotFoundException("Tài khoản bị vô hiệu hóa: " + username);
        }
 
        Set<GrantedAuthority> authorities = user.getRoles().stream()
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
            .collect(Collectors.toSet());
 
        return org.springframework.security.core.userdetails.User
            .withUsername(user.getUsername())
            .password(user.getPassword())
            .authorities(authorities)
            .accountExpired(false)
            .accountLocked(false)
            .credentialsExpired(false)
            .disabled(!user.getActive())
            .build();
    }
}

JWT Token Provider

Tạo một lớp tiện ích để tạo và xác thực JWT token.

src/main/java/com/example/demo/security/JwtTokenProvider.java:

package com.example.demo.security;
 
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
 
import javax.crypto.SecretKey;
import java.util.Date;
 
@Component
public class JwtTokenProvider {
 
    @Value("${app.jwt.secret:mySecretKeyThatIsAtLeast256BitsLongForHS256Algorithm}")
    private String jwtSecret;
 
    @Value("${app.jwt.expiration:86400000}") // 24 giờ tính bằng milliseconds
    private long jwtExpirationMs;
 
    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(jwtSecret.getBytes());
    }
 
    public String generateToken(Authentication authentication) {
        UserDetails userPrincipal = (UserDetails) authentication.getPrincipal();
 
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + jwtExpirationMs);
 
        return Jwts.builder()
            .subject(userPrincipal.getUsername())
            .issuedAt(now)
            .expiration(expiryDate)
            .signWith(getSigningKey())
            .compact();
    }
 
    public String getUsernameFromToken(String token) {
        Claims claims = Jwts.parser()
            .verifyWith(getSigningKey())
            .build()
            .parseSignedClaims(token)
            .getPayload();
 
        return claims.getSubject();
    }
 
    public boolean validateToken(String token) {
        try {
            Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            // Ghi log exception
            return false;
        }
    }
}

Quan trọng: Trong production, lưu trữ JWT secret trong biến môi trường, không hardcode trong code.

Thêm vào application.properties:

# Cấu hình JWT
app.jwt.secret=${JWT_SECRET:mySecretKeyThatIsAtLeast256BitsLongForHS256Algorithm}
app.jwt.expiration=86400000

JWT Authentication Filter

Tạo một filter để xác thực JWT token ở mỗi request.

src/main/java/com/example/demo/security/JwtAuthenticationFilter.java:

package com.example.demo.security;
 
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
 
import java.io.IOException;
 
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
 
    private final JwtTokenProvider tokenProvider;
    private final CustomUserDetailsService userDetailsService;
 
    @Autowired
    public JwtAuthenticationFilter(JwtTokenProvider tokenProvider,
                                   CustomUserDetailsService userDetailsService) {
        this.tokenProvider = tokenProvider;
        this.userDetailsService = userDetailsService;
    }
 
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                   HttpServletResponse response,
                                   FilterChain filterChain) throws ServletException, IOException {
        try {
            String jwt = getJwtFromRequest(request);
 
            if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
                String username = tokenProvider.getUsernameFromToken(jwt);
 
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
 
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception ex) {
            logger.error("Không thể thiết lập xác thực người dùng trong security context", ex);
        }
 
        filterChain.doFilter(request, response);
    }
 
    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

Cấu Hình Security

Cấu hình Spring Security với JWT authentication.

src/main/java/com/example/demo/config/SecurityConfig.java:

package com.example.demo.config;
 
import com.example.demo.security.CustomUserDetailsService;
import com.example.demo.security.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
 
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
 
    private final CustomUserDetailsService userDetailsService;
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
 
    @Autowired
    public SecurityConfig(CustomUserDetailsService userDetailsService,
                         JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.userDetailsService = userDetailsService;
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }
 
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
 
    @Bean
    public DaoAuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userDetailsService);
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }
 
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
        return authConfig.getAuthenticationManager();
    }
 
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/users").hasRole("ADMIN")
                .requestMatchers(HttpMethod.POST, "/api/users").permitAll()
                .requestMatchers(HttpMethod.PUT, "/api/users/**").authenticated()
                .requestMatchers(HttpMethod.DELETE, "/api/users/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            );
 
        http.authenticationProvider(authenticationProvider());
        http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
 
        return http.build();
    }
}

Quy tắc bảo mật:

  • /api/auth/**: Public (đăng nhập, đăng ký)
  • GET /api/users: Chỉ ADMIN
  • POST /api/users: Public (đăng ký)
  • PUT /api/users/**: Người dùng đã xác thực
  • DELETE /api/users/**: Chỉ ADMIN
  • Tất cả endpoint khác: Đã xác thực

Authentication DTOs

Login Request

src/main/java/com/example/demo/dto/LoginRequest.java:

package com.example.demo.dto;
 
import jakarta.validation.constraints.NotBlank;
 
public class LoginRequest {
 
    @NotBlank(message = "Username là bắt buộc")
    private String username;
 
    @NotBlank(message = "Password là bắt buộc")
    private String password;
 
    // Constructors, Getters và Setters
}

JWT Response

src/main/java/com/example/demo/dto/JwtResponse.java:

package com.example.demo.dto;
 
public class JwtResponse {
 
    private String token;
    private String type = "Bearer";
    private String username;
 
    public JwtResponse(String token, String username) {
        this.token = token;
        this.username = username;
    }
 
    // Getters và Setters
}

Authentication Controller

src/main/java/com/example/demo/controller/AuthController.java:

package com.example.demo.controller;
 
import com.example.demo.dto.JwtResponse;
import com.example.demo.dto.LoginRequest;
import com.example.demo.dto.RegisterRequest;
import com.example.demo.entity.Role;
import com.example.demo.entity.User;
import com.example.demo.repository.RoleRepository;
import com.example.demo.repository.UserRepository;
import com.example.demo.security.JwtTokenProvider;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
 
import java.util.HashSet;
import java.util.Set;
 
@RestController
@RequestMapping("/api/auth")
public class AuthController {
 
    private final AuthenticationManager authenticationManager;
    private final UserRepository userRepository;
    private final RoleRepository roleRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtTokenProvider tokenProvider;
 
    @Autowired
    public AuthController(AuthenticationManager authenticationManager,
                         UserRepository userRepository,
                         RoleRepository roleRepository,
                         PasswordEncoder passwordEncoder,
                         JwtTokenProvider tokenProvider) {
        this.authenticationManager = authenticationManager;
        this.userRepository = userRepository;
        this.roleRepository = roleRepository;
        this.passwordEncoder = passwordEncoder;
        this.tokenProvider = tokenProvider;
    }
 
    @PostMapping("/login")
    public ResponseEntity<JwtResponse> login(@Valid @RequestBody LoginRequest loginRequest) {
        Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                loginRequest.getUsername(),
                loginRequest.getPassword()
            )
        );
 
        SecurityContextHolder.getContext().setAuthentication(authentication);
        String jwt = tokenProvider.generateToken(authentication);
 
        return ResponseEntity.ok(new JwtResponse(jwt, loginRequest.getUsername()));
    }
 
    @PostMapping("/register")
    public ResponseEntity<String> register(@Valid @RequestBody RegisterRequest registerRequest) {
        if (userRepository.existsByUsername(registerRequest.getUsername())) {
            return ResponseEntity.badRequest().body("Username đã được sử dụng");
        }
 
        if (userRepository.existsByEmail(registerRequest.getEmail())) {
            return ResponseEntity.badRequest().body("Email đã được sử dụng");
        }
 
        User user = new User();
        user.setUsername(registerRequest.getUsername());
        user.setEmail(registerRequest.getEmail());
        user.setPassword(passwordEncoder.encode(registerRequest.getPassword()));
        user.setFirstName(registerRequest.getFirstName());
        user.setLastName(registerRequest.getLastName());
        user.setActive(true);
 
        Role userRole = roleRepository.findByName("USER")
            .orElseThrow(() -> new RuntimeException("Không tìm thấy role USER mặc định"));
 
        Set<Role> roles = new HashSet<>();
        roles.add(userRole);
        user.setRoles(roles);
 
        userRepository.save(user);
 
        return ResponseEntity.status(HttpStatus.CREATED).body("Đăng ký người dùng thành công");
    }
}

Kiểm Thử API

1. Đăng Ký Người Dùng

curl -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "username": "johndoe",
    "email": "[email protected]",
    "password": "password123",
    "firstName": "John",
    "lastName": "Doe"
  }'

2. Đăng Nhập

curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "username": "johndoe",
    "password": "password123"
  }'

Response:

{
  "token": "eyJhbGciOiJIUzI1NiJ9...",
  "type": "Bearer",
  "username": "johndoe"
}

3. Truy Cập Endpoint Được Bảo Vệ

curl http://localhost:8080/api/users/me \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."

Các Phương Pháp Hay Nhất

1. Lưu Trữ Secrets An Toàn

Không bao giờ hardcode secrets:

# Không tốt
app.jwt.secret=myHardcodedSecret
 
# Tốt - sử dụng biến môi trường
app.jwt.secret=${JWT_SECRET}

2. Sử Dụng Mã Hóa Mật Khẩu Mạnh

BCrypt tự động xử lý salting và kháng rainbow table attacks:

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(12); // Độ mạnh: 12 rounds
}

3. Triển Khai Token Refresh

Cho các session dài hạn, triển khai refresh tokens:

public String generateRefreshToken(Authentication authentication) {
    // Tạo token với thời gian hết hạn dài hơn (7 ngày)
    Date expiryDate = new Date(new Date().getTime() + 604800000);
    // ...
}

4. Thêm Rate Limiting

Ngăn chặn brute force attacks trên endpoint đăng nhập.

5. Sử Dụng HTTPS Trong Production

Luôn bắt buộc HTTPS cho các endpoint xác thực.

6. Triển Khai Account Lockout

Khóa tài khoản sau nhiều lần đăng nhập thất bại.

7. Làm Sạch User Input

Luôn validate và sanitize input để ngăn chặn injection attacks.

8. Ghi Log Security Events

Ghi log các lần thử đăng nhập và lỗi.

Kết Luận

Bạn đã triển khai thành công xác thực và phân quyền dựa trên JWT trong ứng dụng Spring Boot của mình. Các điểm chính:

  • Spring Security cung cấp xác thực và phân quyền mạnh mẽ
  • JWT tokens cho phép xác thực stateless cho REST APIs
  • BCrypt mã hóa mật khẩu an toàn
  • Role-based access control (RBAC) quản lý quyền
  • Method-level security cung cấp phân quyền chi tiết
  • Best practices ngăn chặn các lỗ hổng bảo mật phổ biến

Trong bài hướng dẫn tiếp theo, chúng ta sẽ tìm hiểu về triển khai ứng dụng Spring Boot lên production với Docker và CI/CD pipelines.

Tài Liệu Tham Khảo

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.