انتقل إلى المحتوى الرئيسي

TP2 - API REST مع JWT

أهداف التمرين​

في هذا التمرين، ستقوم بـ:

  1. ✅ إنشاء API REST آمن
  2. ✅ تنفيذ توثيق JWT
  3. ✅ إدارة نقاط النهاية لتسجيل الدخول والتسجيل
  4. ✅ حماية المسارات حسب الدور
  5. ✅ تنفيذ 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 جديد

🎯 الخطوة التالية​

➡️ TP8.3: الأدوار والصلاحيات


⬅️ العودة إلى الوحدة 8