بالدرس الماضي عملنا Sign Up وLogin أساسي بس في مشكلة: مع كل طلب لازم نبعث الـ Username والـ Password. هاشي مش صحيح لأنه دائماً بتعرض الكريدنشلز للخطر عبر الشبكة.
الحل الأفضل هو التوكنز. آلية عملها بسيطة:
إذا فتحتوا موقع JWT.io، رح تلاقوا إنه التوكن هي string مشفر بـ Base64، مكونة من ثلاث أقسام مفصولة بنقطة.
| القسم | الاسم | المحتوى |
|---|---|---|
| الأول | Header | نوع التوكن والـ Algorithm المستخدم |
| الثاني | Payload | الـ Claims (البيانات) |
| الثالث | Signature | التوقيع لضمان سلامة البيانات |
الـ Payload بيحتوي على نوعين من الـ Claims:
sub (صاحب التوكن)، iat (وقت الإنشاء)، exp (وقت الانتهاء)userId أو name أو adminالـ Signature هي الأهم. مهمتها ضمان الـ Integrity للتوكن - يعني إذا حاول أي شخص يغير الـ Payload (مثلاً يغير admin: false لـ admin: true)، التوقيع رح يصير غير صالح والسيرفر رح يرفض التوكن.
الـ Signature بتعتمد على secret key سري مش مشترك مع أحد. اعتبروه باسورد بيحفظه السيرفر بس.
بنضيف ثلاث dependencies، الثلاثة بنفس الـ version (0.12.6):
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
اشترك في النشرة البريدية
دروس جديدة، مقالات، وأدوات مباشرة لبريدك.
بننشئ كلاس JwtHelper يحتوي على كل العمليات المتعلقة بالتوكنز:
@Component
public class JwtHelper {
@Value("${jwt.secret}")
private String secret;
public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
return Jwts.builder()
.claims(extraClaims)
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60))
.signWith(getSigningKey())
.compact();
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public boolean isTokenValid(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
}
}كل دالة بتخدم غرض محدد:
generateToken - ينشئ توكن جديدة بالـ claims، الـ subject (اليوزرنيم)، وقت الإنشاء، ووقت الانتهاءextractUsername - يستخرج اليوزرنيم من التوكنisTokenValid - يتأكد إنه اليوزرنيم صحيح والتوكن ما انتهت مدتهاالـ generateToken عندها overloading: إذا بتعطوها argument وحد بتولد توكن بدون extra claims، وإذا بتعطوها اثنين بتضيف الـ claims الإضافية.
بالـ application.properties بنضيف:
jwt.secret=your-256-bit-sha-secret-key-hereاستخدموا SHA-256 generator لتوليد key قوية. هاي الـ secret لا يجوز يعرفها أحد غير السيرفر. بالـ Production احرصوا ما تحطوها مباشرة بالكود.
لو بدنا نضيف الـ User ID للتوكن كـ custom claim:
Map<String, Object> customClaims = new HashMap<>();
customClaims.put("userId", userAccount.getId());
String token = jwtHelper.generateToken(customClaims, userAccount);بعدين إذا فتحتوا التوكن بـ jwt.io بتشوفوا الـ userId موجود بالـ Payload.
بننشئ LoginRequest DTO:
public record LoginRequest(
@NotBlank(message = "Username is required")
String username,
@Size(min = 5, max = 50, message = "Password must be between 5 and 50 characters")
String password
) {}وبالـ Auth Controller:
@PostMapping("/login")
public ResponseEntity<GlobalResponse<String>> login(
@Valid @RequestBody LoginRequest request) {
String token = authService.login(request);
return new ResponseEntity<>(new GlobalResponse<>(token), HttpStatus.CREATED);
}بالـ AuthService بنضيف الـ Login logic:
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtHelper jwtHelper;
@Autowired
private UserAccountRepository userAccountRepository;
public String login(LoginRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.username(),
request.password()
)
);
UserAccount userAccount = userAccountRepository
.findOneByUsername(request.username())
.orElseThrow(() -> CustomResponseException.badCredentials());
Map<String, Object> customClaims = new HashMap<>();
customClaims.put("userId", userAccount.getId());
return jwtHelper.generateToken(customClaims, userAccount);
}الـ authenticationManager.authenticate() بيتأكد إنه الـ Username والـ Password صحيحين. إذا غلط رح يعمل throw لـ BadCredentialsException تلقائياً.
لازم كمان تضيفوا login/ لقائمة الـ permitAll بالـ Security Config:
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
)الـ Filter هو الحلقة المهمة. كل Request قبل ما يوصل للـ Controller لازم يمر عليه. مهمته:
Authorization header@Component
public class JwtAuthFilter extends OncePerRequestFilter {
@Autowired
private JwtHelper jwtHelper;
@Autowired
private 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 ")) {
String token = authHeader.substring(7);
String username = jwtHelper.extractUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtHelper.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
}
filterChain.doFilter(request, response);
}
}substring(7) بيحذف كلمة Bearer (7 أحرف مع المسافة) لياخذ التوكن بس.
بنضيف الـ Filter للـ SecurityConfig:
@Autowired
private JwtAuthFilter jwtAuthFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.cors(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}addFilterBefore بيخبر Spring Security تشغل الـ JWT Filter قبل الـ Default Authentication Filter.
بالـ HTTP Client (Bruno أو Postman)، بتضيفوا Header:
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
إذا التوكن صحيحة، السيرفر بيرجع الداتا المطلوبة. إذا غلطة أو ما في، بيرجع 403 Forbidden.
بالدرس الجاي رح نضيف Authorization لنتحكم بالصلاحيات حسب الـ Role.