[Java Backend Zero to Hello] [Phase 8] BÀI 8.2: AUTHENTICATION MODULE
📚 Series: Java Backend Zero to Hello 📂 Phân đoạn: Phase 8: Project thực tế 📖 Nội dung: BÀI 8.2: AUTHENTICATION MODULE 💡 Khóa học lập trình Backend Java & Spring Boot chuẩn doanh nghiệp từ con số 0.
BÀI 8.2: AUTHENTICATION MODULE
Mục tiêu
- Implement JWT authentication
- User registration & login
- Refresh token
- Phân quyền với Spring Security
1. USER ENTITY
@Entity
@Table(name = "users")
@EntityListeners(AuditingEntityListener.class)
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 50)
private String username;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Column(name = "full_name", length = 100)
private String fullName;
@Column(length = 20)
private String phone;
@Column(name = "avatar_url")
private String avatarUrl;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private Role role;
@Column(nullable = false)
private boolean enabled = true;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
// UserDetails methods
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enabled;
}
}
Role Enum
public enum Role {
ADMIN, CUSTOMER
}
2. REPOSITORY
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
}
3. DTOs
3.1 Request DTOs
public record RegisterRequest(
@NotBlank @Size(min = 3, max = 50) String username,
@NotBlank @Email String email,
@NotBlank @Size(min = 8) String password,
@Size(max = 100) String fullName,
@Pattern(regexp = "^\\d{10}$") String phone
) {}
public record LoginRequest(
@NotBlank String username, // username hoặc email
@NotBlank String password
) {}
public record RefreshTokenRequest(
@NotBlank String refreshToken
) {}
3.2 Response DTOs
public record AuthResponse(
String accessToken,
String refreshToken,
String tokenType,
long expiresIn,
UserResponse user
) {}
public record UserResponse(
Long id,
String username,
String email,
String fullName,
String phone,
String avatarUrl,
Role role,
LocalDateTime createdAt
) {
public static UserResponse from(User user) {
return new UserResponse(
user.getId(),
user.getUsername(),
user.getEmail(),
user.getFullName(),
user.getPhone(),
user.getAvatarUrl(),
user.getRole(),
user.getCreatedAt()
);
}
}
4. JWT SERVICE
@Service
@Slf4j
public class JwtService {
@Value("${app.jwt.secret}")
private String secret;
@Value("${app.jwt.expiration}")
private long expiration;
@Value("${app.jwt.refresh-expiration}")
private long refreshExpiration;
public String generateAccessToken(UserDetails userDetails) {
return generateToken(userDetails, expiration, "access");
}
public String generateRefreshToken(UserDetails userDetails) {
return generateToken(userDetails, refreshExpiration, "refresh");
}
private String generateToken(UserDetails userDetails, long expiration, String type) {
Map<String, Object> claims = new HashMap<>();
claims.put("type", type);
if (userDetails instanceof User user) {
claims.put("userId", user.getId());
claims.put("role", user.getRole().name());
}
return Jwts.builder()
.claims(claims)
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey())
.compact();
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public boolean isAccessToken(String token) {
return "access".equals(extractClaim(token, claims -> claims.get("type", String.class)));
}
public boolean isValid(String token, UserDetails userDetails) {
try {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isExpired(token);
} catch (Exception e) {
return false;
}
}
private boolean isExpired(String token) {
return extractClaim(token, Claims::getExpiration).before(new Date());
}
private <T> T extractClaim(String token, Function<Claims, T> resolver) {
Claims claims = extractAllClaims(token);
return resolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
private SecretKey getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
return Keys.hmacShaKeyFor(keyBytes);
}
}
5. JWT AUTHENTICATION FILTER
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String jwt = authHeader.substring(7);
try {
String username = jwtService.extractUsername(jwt);
if (username != null && SecurityContextHolder.getContext()
.getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isValid(jwt, userDetails) && jwtService.isAccessToken(jwt)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource()
.buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} catch (Exception e) {
log.error("JWT validation failed: {}", e.getMessage());
}
filterChain.doFilter(request, response);
}
}
6. SECURITY CONFIG
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final CustomUserDetailsService userDetailsService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:3000"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
7. AUTH SERVICE
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
@Transactional
public AuthResponse register(RegisterRequest request) {
if (userRepository.existsByUsername(request.username())) {
throw new DuplicateException("Username already exists");
}
if (userRepository.existsByEmail(request.email())) {
throw new DuplicateException("Email already exists");
}
User user = User.builder()
.username(request.username())
.email(request.email())
.password(passwordEncoder.encode(request.password()))
.fullName(request.fullName())
.phone(request.phone())
.role(Role.CUSTOMER)
.enabled(true)
.build();
User saved = userRepository.save(user);
log.info("User registered: {}", saved.getUsername());
return generateAuthResponse(saved);
}
public AuthResponse login(LoginRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.username(), request.password()
)
);
User user = userRepository.findByUsername(request.username())
.or(() -> userRepository.findByEmail(request.username()))
.orElseThrow(() -> new BadCredentialsException("Invalid credentials"));
log.info("User logged in: {}", user.getUsername());
return generateAuthResponse(user);
}
public AuthResponse refreshToken(RefreshTokenRequest request) {
String username = jwtService.extractUsername(request.refreshToken());
User user = userRepository.findByUsername(username)
.or(() -> userRepository.findByEmail(username))
.orElseThrow(() -> new NotFoundException("User not found"));
if (!jwtService.isValid(request.refreshToken(), user)) {
throw new BusinessException("INVALID_TOKEN", "Invalid refresh token");
}
return generateAuthResponse(user);
}
private AuthResponse generateAuthResponse(User user) {
String accessToken = jwtService.generateAccessToken(user);
String refreshToken = jwtService.generateRefreshToken(user);
return new AuthResponse(
accessToken,
refreshToken,
"Bearer",
86400000,
UserResponse.from(user)
);
}
}
8. AUTH CONTROLLER
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
@Tag(name = "Authentication", description = "Authentication APIs")
public class AuthController {
private final AuthService authService;
@PostMapping("/register")
@Operation(summary = "Register new user")
public ResponseEntity<ApiResponse<AuthResponse>> register(
@RequestBody @Valid RegisterRequest request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success("Registration successful", authService.register(request)));
}
@PostMapping("/login")
@Operation(summary = "Login")
public ApiResponse<AuthResponse> login(@RequestBody @Valid LoginRequest request) {
return ApiResponse.success("Login successful", authService.login(request));
}
@PostMapping("/refresh-token")
@Operation(summary = "Refresh access token")
public ApiResponse<AuthResponse> refreshToken(
@RequestBody @Valid RefreshTokenRequest request) {
return ApiResponse.success(authService.refreshToken(request));
}
}
9. USER CONTROLLER
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Tag(name = "User", description = "User management APIs")
public class UserController {
private final UserService userService;
@GetMapping("/me")
@Operation(summary = "Get current user")
public ApiResponse<UserResponse> getCurrentUser(
@AuthenticationPrincipal User user) {
return ApiResponse.success(UserResponse.from(user));
}
@PutMapping("/me")
@Operation(summary = "Update current user")
public ApiResponse<UserResponse> updateCurrentUser(
@AuthenticationPrincipal User user,
@RequestBody @Valid UpdateUserRequest request) {
return ApiResponse.success(userService.update(user.getId(), request));
}
@PutMapping("/me/password")
@Operation(summary = "Change password")
public ApiResponse<Void> changePassword(
@AuthenticationPrincipal User user,
@RequestBody @Valid ChangePasswordRequest request) {
userService.changePassword(user.getId(), request);
return ApiResponse.success("Password changed", null);
}
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Get all users (Admin)")
public ApiResponse<PageResponse<UserResponse>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
return ApiResponse.success(userService.findAll(page, size));
}
}
10. TESTING
10.1 Unit Test
@ExtendWith(MockitoExtension.class)
class AuthServiceTest {
@Mock private UserRepository userRepository;
@Mock private PasswordEncoder passwordEncoder;
@Mock private JwtService jwtService;
@Mock private AuthenticationManager authenticationManager;
@InjectMocks private AuthService authService;
@Test
void shouldRegisterUser() {
RegisterRequest request = new RegisterRequest(
"an", "an@example.com", "password123", "An", "0123456789"
);
when(userRepository.existsByUsername("an")).thenReturn(false);
when(userRepository.existsByEmail("an@example.com")).thenReturn(false);
when(passwordEncoder.encode("password123")).thenReturn("encoded");
when(userRepository.save(any(User.class))).thenAnswer(inv -> {
User u = inv.getArgument(0);
u.setId(1L);
return u;
});
when(jwtService.generateAccessToken(any())).thenReturn("access-token");
when(jwtService.generateRefreshToken(any())).thenReturn("refresh-token");
AuthResponse response = authService.register(request);
assertThat(response.accessToken()).isEqualTo("access-token");
assertThat(response.user().username()).isEqualTo("an");
}
@Test
void shouldThrowWhenUsernameExists() {
RegisterRequest request = new RegisterRequest(
"an", "an@example.com", "password123", "An", null
);
when(userRepository.existsByUsername("an")).thenReturn(true);
assertThatThrownBy(() -> authService.register(request))
.isInstanceOf(DuplicateException.class);
}
}
10.2 Integration Test
@SpringBootTest
@AutoConfigureMockMvc
class AuthControllerIntegrationTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private UserRepository userRepository;
@Test
void shouldRegisterAndLogin() throws Exception {
// Register
RegisterRequest register = new RegisterRequest(
"testuser", "test@example.com", "password123", "Test", null
);
mockMvc.perform(post("/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(register)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.accessToken").exists());
// Login
LoginRequest login = new LoginRequest("testuser", "password123");
mockMvc.perform(post("/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(login)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.accessToken").exists());
}
}
11. BÀI TẬP
Bài 1: User Entity & Repository
Tạo User entity với các trường cần thiết.
Bài 2: JWT Service
Implement JwtService với access/refresh token.
Bài 3: Auth Flow
Implement register, login, refresh token.
Bài 4: Test
Viết unit test và integration test.
Bài tiếp theo: 8.3 Product & Category Module
🧭 Điều hướng bài học
- ⬅️ Bài trước: BÀI 8.1: SETUP PROJECT E-COMMERCE
- ➡️ Bài tiếp theo: BÀI 8.3: PRODUCT & CATEGORY MODULE
- 📑 Toàn bộ lộ trình: Java Backend Zero to Hello
⭐ Hãy bookmark (clip) lại series để tiện theo dõi các bài học tiếp theo nhé!
All rights reserved