TP2 - API REST مع JWT
أهداف التمرين
في هذا التمرين، ستقوم بـ:
- ✅ إنشاء API REST آمن
- ✅ تنفيذ توثيق JWT
- ✅ إدارة نقاط النهاية لتسجيل الدخول والتسجيل
- ✅ حماية المسارات حسب الدور
- ✅ تنفيذ refresh token
المدة المقدرة: ساعتان
السياق
أنت تقوم بإنشاء API للواجهة الخلفية لتطبيق جوال لإدارة الملاحظات. يجب أن يكون الـ API عديم الحالة مع توثيق JWT.
الجزء 1: إعداد المشروع
تبعيات Maven
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- JWT -->
<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>
</dependencies>
التكوين
# application.properties
spring.datasource.url=jdbc:h2:mem:jwtdb
spring.h2.console.enabled=true
spring.jpa.hibernate.ddl-auto=create-drop
# JWT Configuration
jwt.secret=404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
jwt.expiration=3600000
jwt.refresh-expiration=604800000
الجزء 2: JwtService
@Service
@RequiredArgsConstructor
public class JwtService {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.expiration}")
private long jwtExpiration;
@Value("${jwt.refresh-expiration}")
private long refreshExpiration;
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
public String generateToken(UserDetails userDetails) {
return generateToken(new HashMap<>(), userDetails);
}
public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
return buildToken(extraClaims, userDetails, jwtExpiration);
}
public String generateRefreshToken(UserDetails userDetails) {
return buildToken(new HashMap<>(), userDetails, refreshExpiration);
}
private String buildToken(Map<String, Object> extraClaims, UserDetails userDetails, long expiration) {
return Jwts.builder()
.claims(extraClaims)
.subject(userDetails.getUsername())
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSignInKey())
.compact();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
private Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSignInKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
private SecretKey getSignInKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
}
الجزء 3: JwtAuthenticationFilter
@Component
@RequiredArgsConstructor
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 {
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
try {
final String jwt = authHeader.substring(7);
final String username = jwtService.extractUsername(jwt);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} catch (Exception e) {
// Token غير صالح - المتابعة بدون توثيق
}
filterChain.doFilter(request, response);
}
}
الجزء 4: SecurityConfig
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/h2-console/**").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.headers(headers -> headers.frameOptions(f -> f.disable()))
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
الجزء 5: DTOs
// LoginRequest.java
@Data
public class LoginRequest {
@NotBlank(message = "اسم المستخدم مطلوب")
private String username;
@NotBlank(message = "كلمة المرور مطلوبة")
private String password;
}
// RegisterRequest.java
@Data
public class RegisterRequest {
@NotBlank @Size(min = 3, max = 50)
private String username;
@NotBlank @Email
private String email;
@NotBlank @Size(min = 6)
private String password;
}
// AuthResponse.java
@Data
@Builder
public class AuthResponse {
private String accessToken;
private String refreshToken;
private String username;
private List<String> roles;
}
// RefreshRequest.java
@Data
public class RefreshRequest {
@NotBlank
private String refreshToken;
}
الجزء 6: AuthController
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final UserDetailsService userDetailsService;
private final JwtService jwtService;
private final UserService userService;
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
User user = userService.createUser(request.getUsername(), request.getEmail(), request.getPassword());
UserDetails userDetails = userDetailsService.loadUserByUsername(user.getUsername());
return ResponseEntity.status(HttpStatus.CREATED).body(createAuthResponse(userDetails));
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
);
UserDetails userDetails = userDetailsService.loadUserByUsername(request.getUsername());
return ResponseEntity.ok(createAuthResponse(userDetails));
}
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(@Valid @RequestBody RefreshRequest request) {
String username = jwtService.extractUsername(request.getRefreshToken());
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(request.getRefreshToken(), userDetails)) {
String accessToken = jwtService.generateToken(userDetails);
return ResponseEntity.ok(AuthResponse.builder()
.accessToken(accessToken)
.refreshToken(request.getRefreshToken())
.username(username)
.roles(userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList())
.build());
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
private AuthResponse createAuthResponse(UserDetails userDetails) {
return AuthResponse.builder()
.accessToken(jwtService.generateToken(userDetails))
.refreshToken(jwtService.generateRefreshToken(userDetails))
.username(userDetails.getUsername())
.roles(userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList())
.build();
}
}
الجزء 7: API محمي
NoteController
@RestController
@RequestMapping("/api/notes")
@RequiredArgsConstructor
public class NoteController {
private final NoteRepository noteRepository;
@GetMapping
public List<Note> getMyNotes(@AuthenticationPrincipal UserDetails userDetails) {
return noteRepository.findByUsername(userDetails.getUsername());
}
@PostMapping
public Note createNote(@RequestBody Note note, @AuthenticationPrincipal UserDetails userDetails) {
note.setUsername(userDetails.getUsername());
return noteRepository.save(note);
}
@DeleteMapping("/{id}")
@PreAuthorize("@noteRepository.findById(#id).orElse(null)?.username == authentication.name or hasRole('ADMIN')")
public void deleteNote(@PathVariable Long id) {
noteRepository.deleteById(id);
}
}
الجزء 8: اختبارات باستخدام cURL
# 1. التسجيل
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"testuser","email":"test@test.com","password":"password123"}'
# 2. تسجيل الدخول
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"testuser","password":"password123"}'
# الرد: {"accessToken":"eyJ...","refreshToken":"eyJ...","username":"testuser","roles":["ROLE_USER"]}
# 3. الوصول المحمي (استبدل TOKEN بـ accessToken المستلم)
curl http://localhost:8080/api/notes \
-H "Authorization: Bearer TOKEN"
# 4. إنشاء ملاحظة
curl -X POST http://localhost:8080/api/notes \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"ملاحظتي","content":"محتوى الملاحظة"}'
# 5. refresh token
curl -X POST http://localhost:8080/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refreshToken":"REFRESH_TOKEN"}'
قائمة التحقق
- POST /api/auth/register ينشئ مستخدمًا
- POST /api/auth/login يعيد الرموز
- GET /api/notes بدون رمز → 401
- GET /api/notes مع رمز → 200
- رمز منتهي الصلاحية → 401
- POST /api/auth/refresh → access token جديد