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

المشروع د: نظام التحقق مع التحقق عبر البريد الإلكتروني

الوصف​

تطوير API كامل لإدارة المستخدمين مع التحقق عبر البريد الإلكتروني، إدارة الأدوار واستعادة كلمة المرور.


الميزات المطلوبة​

1. التسجيل مع التحقق عبر البريد الإلكتروني​

POST /api/auth/register     - التسجيل (إرسال بريد إلكتروني للتحقق)
GET /api/auth/verify - التحقق من الحساب عبر رمز البريد الإلكتروني
POST /api/auth/resend - إعادة إرسال البريد الإلكتروني للتحقق

2. تسجيل الدخول وJWT​

POST /api/auth/login        - تسجيل الدخول (يتطلب حساب صالح)
POST /api/auth/refresh - تحديث الرمز
POST /api/auth/logout - تسجيل الخروج

3. استعادة كلمة المرور​

POST /api/auth/forgot-password  - طلب إعادة تعيين (إرسال بريد إلكتروني)
POST /api/auth/reset-password - تغيير كلمة المرور عبر الرمز

4. إدارة المستخدمين​

GET    /api/users/me            - ملفي الشخصي
PUT /api/users/me - تعديل ملفي الشخصي
PUT /api/users/me/password - تغيير كلمة المرور الخاصة بي
GET /api/admin/users - قائمة المستخدمين (ADMIN)
PUT /api/admin/users/{id}/role - تغيير الدور (ADMIN)
DELETE /api/admin/users/{id} - حذف المستخدم (ADMIN)

مخطط قاعدة البيانات​

-- Table users
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
role VARCHAR(20) DEFAULT 'USER',
enabled BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);

-- Table verification_tokens (validation email)
CREATE TABLE verification_tokens (
id BIGSERIAL PRIMARY KEY,
token VARCHAR(255) UNIQUE NOT NULL,
user_id BIGINT REFERENCES users(id),
expiry_date TIMESTAMP NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Table password_reset_tokens
CREATE TABLE password_reset_tokens (
id BIGSERIAL PRIMARY KEY,
token VARCHAR(255) UNIQUE NOT NULL,
user_id BIGINT REFERENCES users(id),
expiry_date TIMESTAMP NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

الكيانات JPA​

User Entity​

package com.example.auth.model;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Entity
@Table(name = "users")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@Builder
public class User {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(unique = true, nullable = false)
private String email;

@Column(nullable = false)
private String password;

private String firstName;
private String lastName;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Builder.Default
private Role role = Role.USER;

@Builder.Default
private boolean enabled = false; // false حتى التحقق من البريد الإلكتروني

private LocalDateTime createdAt;
private LocalDateTime updatedAt;

@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}

@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}

public enum Role {
USER, ADMIN
}
}

VerificationToken Entity​

package com.example.auth.model;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;
import java.util.UUID;

@Entity
@Table(name = "verification_tokens")
@Getter @Setter
@NoArgsConstructor
public class VerificationToken {

private static final int EXPIRATION_HOURS = 24;

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(unique = true, nullable = false)
private String token;

@OneToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private User user;

private LocalDateTime expiryDate;

@Builder.Default
private boolean used = false;

private LocalDateTime createdAt;

public VerificationToken(User user) {
this.user = user;
this.token = UUID.randomUUID().toString();
this.expiryDate = LocalDateTime.now().plusHours(EXPIRATION_HOURS);
this.createdAt = LocalDateTime.now();
}

public boolean isExpired() {
return LocalDateTime.now().isAfter(expiryDate);
}
}

PasswordResetToken Entity​

package com.example.auth.model;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;
import java.util.UUID;

@Entity
@Table(name = "password_reset_tokens")
@Getter @Setter
@NoArgsConstructor
public class PasswordResetToken {

private static final int EXPIRATION_HOURS = 1; // 1 ساعة لإعادة التعيين

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(unique = true, nullable = false)
private String token;

@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private User user;

private LocalDateTime expiryDate;

@Builder.Default
private boolean used = false;

private LocalDateTime createdAt;

public PasswordResetToken(User user) {
this.user = user;
this.token = UUID.randomUUID().toString();
this.expiryDate = LocalDateTime.now().plusHours(EXPIRATION_HOURS);
this.createdAt = LocalDateTime.now();
}

public boolean isExpired() {
return LocalDateTime.now().isAfter(expiryDate);
}
}

DTOs​

Request DTOs​

// RegisterRequest.java
package com.example.auth.dto.request;

import jakarta.validation.constraints.*;
import lombok.Data;

@Data
public class RegisterRequest {

@NotBlank(message = "البريد الإلكتروني مطلوب")
@Email(message = "البريد الإلكتروني غير صالح")
private String email;

@NotBlank(message = "كلمة المرور مطلوبة")
@Size(min = 8, message = "الحد الأدنى 8 أحرف")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).*$",
message = "يجب أن تحتوي على حرف كبير وصغير ورقم"
)
private String password;

@NotBlank(message = "الاسم الأول مطلوب")
private String firstName;

@NotBlank(message = "الاسم الأخير مطلوب")
private String lastName;
}
// LoginRequest.java
@Data
public class LoginRequest {

@NotBlank(message = "البريد الإلكتروني مطلوب")
@Email
private String email;

@NotBlank(message = "كلمة المرور مطلوبة")
private String password;
}
// ForgotPasswordRequest.java
@Data
public class ForgotPasswordRequest {

@NotBlank(message = "البريد الإلكتروني مطلوب")
@Email
private String email;
}
// ResetPasswordRequest.java
@Data
public class ResetPasswordRequest {

@NotBlank(message = "الرمز مطلوب")
private String token;

@NotBlank(message = "كلمة المرور الجديدة مطلوبة")
@Size(min = 8)
private String newPassword;
}
// ChangeRoleRequest.java
@Data
public class ChangeRoleRequest {

@NotNull
private User.Role role;
}

Response DTOs​

// AuthResponse.java
@Data
@Builder
public class AuthResponse {
private String token;
private String type;
private Long expiresIn;
private UserDto user;
}
// UserDto.java
@Data
@Builder
public class UserDto {
private Long id;
private String email;
private String firstName;
private String lastName;
private String role;
private boolean enabled;
private LocalDateTime createdAt;
}
// MessageResponse.java
@Data
@AllArgsConstructor
public class MessageResponse {
private String message;
}

الخدمات​

EmailService​

package com.example.auth.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class EmailService {

private static final Logger log = LoggerFactory.getLogger(EmailService.class);

private final JavaMailSender mailSender;

@Value("${app.base-url}")
private String baseUrl;

@Value("${spring.mail.username}")
private String fromEmail;

public EmailService(JavaMailSender mailSender) {
this.mailSender = mailSender;
}

@Async
public void sendVerificationEmail(String to, String token) {
String verifyUrl = baseUrl + "/api/auth/verify?token=" + token;

SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(fromEmail);
message.setTo(to);
message.setSubject("تحقق من حسابك");
message.setText(
"مرحبًا!\n\n" +
"انقر على الرابط التالي للتحقق من حسابك:\n\n" +
verifyUrl + "\n\n" +
"هذا الرابط ينتهي صلاحيته خلال 24 ساعة.\n\n" +
"إذا لم تقم بإنشاء حساب، تجاهل هذا البريد الإلكتروني."
);

try {
mailSender.send(message);
log.info("تم إرسال بريد التحقق إلى: {}", to);
} catch (Exception e) {
log.error("خطأ في إرسال البريد إلى {}: {}", to, e.getMessage());
}
}

@Async
public void sendPasswordResetEmail(String to, String token) {
String resetUrl = baseUrl + "/reset-password?token=" + token;

SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(fromEmail);
message.setTo(to);
message.setSubject("إعادة تعيين كلمة المرور الخاصة بك");
message.setText(
"لقد طلبت إعادة تعيين كلمة المرور.\n\n" +
"انقر على الرابط التالي لاختيار كلمة مرور جديدة:\n\n" +
resetUrl + "\n\n" +
"هذا الرابط ينتهي صلاحيته خلال ساعة واحدة.\n\n" +
"إذا لم تقم بهذا الطلب، تجاهل هذا البريد الإلكتروني."
);

try {
mailSender.send(message);
log.info("تم إرسال بريد إعادة التعيين إلى: {}", to);
} catch (Exception e) {
log.error("خطأ في إرسال بريد إعادة التعيين إلى {}: {}", to, e.getMessage());
}
}
}

AuthService​

package com.example.auth.service;

import com.example.auth.dto.request.*;
import com.example.auth.dto.response.*;
import com.example.auth.exception.*;
import com.example.auth.model.*;
import com.example.auth.repository.*;
import com.example.auth.security.JwtService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.*;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AuthService {

private static final Logger log = LoggerFactory.getLogger(AuthService.class);

private final UserRepository userRepository;
private final VerificationTokenRepository verificationTokenRepository;
private final PasswordResetTokenRepository passwordResetTokenRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
private final EmailService emailService;

// Constructor injection...

@Transactional
public MessageResponse register(RegisterRequest request) {
// التحقق مما إذا كان البريد الإلكتروني موجودًا
if (userRepository.existsByEmail(request.getEmail())) {
throw new EmailAlreadyExistsException("البريد الإلكتروني مستخدم بالفعل");
}

// إنشاء المستخدم (غير مفعل)
User user = User.builder()
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.firstName(request.getFirstName())
.lastName(request.getLastName())
.role(User.Role.USER)
.enabled(false)
.build();

userRepository.save(user);

// إنشاء رمز التحقق
VerificationToken verificationToken = new VerificationToken(user);
verificationTokenRepository.save(verificationToken);

// إرسال البريد الإلكتروني
emailService.sendVerificationEmail(user.getEmail(), verificationToken.getToken());

log.info("تم تسجيل المستخدم: {}", user.getEmail());

return new MessageResponse(
"تم التسجيل بنجاح! تحقق من بريدك الإلكتروني لتفعيل حسابك."
);
}

@Transactional
public MessageResponse verifyEmail(String token) {
VerificationToken verificationToken = verificationTokenRepository
.findByToken(token)
.orElseThrow(() -> new InvalidTokenException("رمز غير صالح"));

if (verificationToken.isUsed()) {
throw new InvalidTokenException("الرمز مستخدم بالفعل");
}

if (verificationToken.isExpired()) {
throw new InvalidTokenException("الرمز منتهي الصلاحية");
}

// تفعيل المستخدم
User user = verificationToken.getUser();
user.setEnabled(true);
userRepository.save(user);

// وضع علامة على الرمز كمستخدم
verificationToken.setUsed(true);
verificationTokenRepository.save(verificationToken);

log.info("تم تفعيل الحساب: {}", user.getEmail());

return new MessageResponse("تم تفعيل الحساب بنجاح! يمكنك الآن تسجيل الدخول.");
}

@Transactional
public MessageResponse resendVerificationEmail(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UserNotFoundException("المستخدم غير موجود"));

if (user.isEnabled()) {
throw new BadRequestException("الحساب مفعل بالفعل");
}

// إبطال الرموز القديمة
verificationTokenRepository.deleteByUser(user);

// إنشاء رمز جديد
VerificationToken newToken = new VerificationToken(user);
verificationTokenRepository.save(newToken);

// إرسال البريد الإلكتروني
emailService.sendVerificationEmail(user.getEmail(), newToken.getToken());

return new MessageResponse("تم إعادة إرسال البريد الإلكتروني للتحقق");
}

public AuthResponse login(LoginRequest request) {
// التحقق مما إذا كان الحساب مفعل
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new BadCredentialsException("البريد الإلكتروني أو كلمة المرور غير صحيحة"));

if (!user.isEnabled()) {
throw new AccountNotVerifiedException("الحساب غير مفعل. تحقق من بريدك الإلكتروني.");
}

// المصادقة
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getEmail(),
request.getPassword()
)
);

// توليد JWT
String token = jwtService.generateToken(user);

log.info("تم تسجيل الدخول بنجاح: {}", user.getEmail());

return AuthResponse.builder()
.token(token)
.type("Bearer")
.expiresIn(jwtService.getExpiration())
.user(mapToUserDto(user))
.build();
}

@Transactional
public MessageResponse forgotPassword(ForgotPasswordRequest request) {
User user = userRepository.findByEmail(request.getEmail())
.orElse(null);

// دائمًا إرجاع نفس الرسالة (الأمان)
if (user != null && user.isEnabled()) {
// إبطال الرموز القديمة
passwordResetTokenRepository.deleteByUser(user);

// إنشاء رمز جديد
PasswordResetToken resetToken = new PasswordResetToken(user);
passwordResetTokenRepository.save(resetToken);

// إرسال البريد الإلكتروني
emailService.sendPasswordResetEmail(user.getEmail(), resetToken.getToken());
}

return new MessageResponse(
"إذا كان البريد الإلكتروني موجودًا، تم إرسال رابط إعادة التعيين."
);
}

@Transactional
public MessageResponse resetPassword(ResetPasswordRequest request) {
PasswordResetToken resetToken = passwordResetTokenRepository
.findByToken(request.getToken())
.orElseThrow(() -> new InvalidTokenException("رمز غير صالح"));

if (resetToken.isUsed()) {
throw new InvalidTokenException("الرمز مستخدم بالفعل");
}

if (resetToken.isExpired()) {
throw new InvalidTokenException("الرمز منتهي الصلاحية");
}

// تغيير كلمة المرور
User user = resetToken.getUser();
user.setPassword(passwordEncoder.encode(request.getNewPassword()));
userRepository.save(user);

// وضع علامة على الرمز كمستخدم
resetToken.setUsed(true);
passwordResetTokenRepository.save(resetToken);

log.info("تم إعادة تعيين كلمة المرور: {}", user.getEmail());

return new MessageResponse("تم إعادة تعيين كلمة المرور بنجاح");
}

private UserDto mapToUserDto(User user) {
return UserDto.builder()
.id(user.getId())
.email(user.getEmail())
.firstName(user.getFirstName())
.lastName(user.getLastName())
.role(user.getRole().name())
.enabled(user.isEnabled())
.createdAt(user.getCreatedAt())
.build();
}
}

UserService (Admin)​

package com.example.auth.service;

import com.example.auth.dto.request.ChangeRoleRequest;
import com.example.auth.dto.response.UserDto;
import com.example.auth.model.User;
import com.example.auth.repository.UserRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class UserService {

private final UserRepository userRepository;

public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}

public Page<UserDto> getAllUsers(Pageable pageable) {
return userRepository.findAll(pageable)
.map(this::mapToDto);
}

@Transactional
public UserDto changeRole(Long userId, ChangeRoleRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("المستخدم غير موجود"));

user.setRole(request.getRole());
User updated = userRepository.save(user);

return mapToDto(updated);
}

@Transactional
public void deleteUser(Long userId) {
if (!userRepository.existsById(userId)) {
throw new RuntimeException("المستخدم غير موجود");
}
userRepository.deleteById(userId);
}

private UserDto mapToDto(User user) {
return UserDto.builder()
.id(user.getId())
.email(user.getEmail())
.firstName(user.getFirstName())
.lastName(user.getLastName())
.role(user.getRole().name())
.enabled(user.isEnabled())
.createdAt(user.getCreatedAt())
.build();
}
}

Controllers​

AuthController​

package com.example.auth.controller;

import com.example.auth.dto.request.*;
import com.example.auth.dto.response.*;
import com.example.auth.service.AuthService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

private final AuthService authService;

public AuthController(AuthService authService) {
this.authService = authService;
}

@PostMapping("/register")
public ResponseEntity<MessageResponse> register(
@Valid @RequestBody RegisterRequest request) {
return ResponseEntity.ok(authService.register(request));
}

@GetMapping("/verify")
public ResponseEntity<MessageResponse> verifyEmail(@RequestParam String token) {
return ResponseEntity.ok(authService.verifyEmail(token));
}

@PostMapping("/resend")
public ResponseEntity<MessageResponse> resendVerification(
@RequestParam String email) {
return ResponseEntity.ok(authService.resendVerificationEmail(email));
}

@PostMapping("/login")
public ResponseEntity<AuthResponse> login(
@Valid @RequestBody LoginRequest request) {
return ResponseEntity.ok(authService.login(request));
}

@PostMapping("/forgot-password")
public ResponseEntity<MessageResponse> forgotPassword(
@Valid @RequestBody ForgotPasswordRequest request) {
return ResponseEntity.ok(authService.forgotPassword(request));
}

@PostMapping("/reset-password")
public ResponseEntity<MessageResponse> resetPassword(
@Valid @RequestBody ResetPasswordRequest request) {
return ResponseEntity.ok(authService.resetPassword(request));
}
}

AdminController​

package com.example.auth.controller;

import com.example.auth.dto.request.ChangeRoleRequest;
import com.example.auth.dto.response.UserDto;
import com.example.auth.service.UserService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/admin")
@PreAuthorize("hasRole('ADMIN')")
public class AdminController {

private final UserService userService;

public AdminController(UserService userService) {
this.userService = userService;
}

@GetMapping("/users")
public ResponseEntity<Page<UserDto>> getAllUsers(Pageable pageable) {
return ResponseEntity.ok(userService.getAllUsers(pageable));
}

@PutMapping("/users/{id}/role")
public ResponseEntity<UserDto> changeUserRole(
@PathVariable Long id,
@Valid @RequestBody ChangeRoleRequest request) {
return ResponseEntity.ok(userService.changeRole(id, request));
}

@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.noContent().build();
}
}

تكوين البريد الإلكتروني​

application.yml​

spring:
mail:
host: smtp.gmail.com
port: 587
username: ${MAIL_USERNAME}
password: ${MAIL_PASSWORD}
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true

app:
base-url: http://localhost:8080
jwt:
secret: ${JWT_SECRET}
expiration: 86400000 # 24 ساعة

لجيميل​

  1. تفعيل "التحقق بخطوتين"
  2. إنشاء "كلمة مرور للتطبيق" في إعدادات جوجل
  3. استخدام هذه الكلمة في MAIL_PASSWORD

البديل: MailHog (Dev)​

# بدء MailHog (يعترض البريد الإلكتروني)
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog

# تكوين application-dev.yml
spring:
mail:
host: localhost
port: 1025
# لا حاجة لاسم المستخدم/كلمة المرور

# عرض البريد الإلكتروني: http://localhost:8025

التدفق الكامل​

1. التسجيل​

POST /api/auth/register
{
"email": "john@example.com",
"password": "Password123",
"firstName": "John",
"lastName": "Doe"
}

Response: { "message": "تم التسجيل بنجاح! تحقق من بريدك الإلكتروني..." }

2. التحقق عبر البريد الإلكتروني​

GET /api/auth/verify?token=abc123-def456-...

Response: { "message": "تم تفعيل الحساب بنجاح!" }

3. تسجيل الدخول​

POST /api/auth/login
{
"email": "john@example.com",
"password": "Password123"
}

Response: {
"token": "eyJhbGciOiJIUzI1NiIs...",
"type": "Bearer",
"expiresIn": 86400000,
"user": {
"id": 1,
"email": "john@example.com",
"role": "USER"
}
}

4. نسيت كلمة المرور​

POST /api/auth/forgot-password
{
"email": "john@example.com"
}

Response: { "message": "إذا كان البريد الإلكتروني موجودًا، تم إرسال رابط..." }

5. إعادة تعيين كلمة المرور​

POST /api/auth/reset-password
{
"token": "xyz789-...",
"newPassword": "NewPassword456"
}

Response: { "message": "تم إعادة تعيين كلمة المرور بنجاح" }

6. تغيير الدور (Admin)​

PUT /api/admin/users/1/role
Authorization: Bearer <admin-token>
{
"role": "ADMIN"
}

Response: {
"id": 1,
"email": "john@example.com",
"role": "ADMIN"
}

الاختبارات المطلوبة​

الاختبارالوصف
التسجيلإرسال البريد الإلكتروني، المستخدم غير مفعل
التحققالرمز الصالح يفعل الحساب
الرمز منتهي الصلاحيةخطأ إذا كان الرمز منتهي الصلاحية
تسجيل الدخولفشل إذا لم يتم التحقق
نسيت كلمة المرورإرسال البريد الإلكتروني
إعادة تعيين كلمة المرورتغيير كلمة المرور
تغيير الدوريمكن للمسؤول تغيير الدور

المكافآت​

الميزةالنقاط
تحديد المعدل على auth+3
تاريخ تسجيل الدخول+3
الحظر بعد X محاولات+5
2FA (TOTP)+10
OAuth2 (Google)+10

العودة إلى المشروع النهائي