Подтверждение электронной почты с помощью Spring Boot & Angular

от автора

Всем привет! Мы с вами поговорим о важном аспекте безопасности — подтверждении почты пользователей. Мы расскажем, как сделать это с использованием Spring Boot и Angular, двух мощных инструментов для создания современных веб-приложений.

Шаг за шагом разберемся, как настроить подтверждение почты и обеспечить безопасное взаимодействие между клиентской и серверной частями нашего проекта. Тогда начнем!

Архитектура веб-приложения

User service управляет данными пользователей, включая операции с базой данных. Он обрабатывает запросы на регистрацию и ее подтверждение. В данной архитектуре, Apache Kafka используется для отправки сообщений в Mail Service. Mail service создает и отправляет электронные сообщения подтверждения с помощью «SMTP». Клиент подтверждает электронную почту и завершается сам процесс регистрации.

Подготовка к разработке

Сперва, надо запустить брокер сообщений Apache Kafka. Ниже файл Docker Compose описывает конфигурацию для запуска и настройки среды Kafka и Zookeeper в контейнерах Docker. В результате, Zookeeper и Kafka будут доступны через порт 9092.

version: '3' services:   zookeeper:     image: confluentinc/cp-zookeeper:7.0.1     networks:       - broker-kafka     environment:       ZOOKEEPER_CLIENT_PORT: 2181       ZOOKEEPER_TICK_TIME: 2000   kafka:     image: confluentinc/cp-kafka:7.0.1     networks:       - broker-kafka     depends_on:       - zookeeper     ports:       - "9092:9092"     environment:       KAFKA_BROKER_ID: 1       KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181       KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092       KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT       KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT       KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 networks:   broker-kafka:     driver: bridge

User service

application.yml
server:   servlet:     context-path: /api/v1/user/   port: 3000  spring:   datasource:     url: jdbc:postgresql://localhost:5432/habr     username: postgres     password: postgres     driver-class-name: org.postgresql.Driver   jpa:     hibernate:       ddl-auto: update   jackson:     default-property-inclusion: non_default   kafka:     producer:       key-serializer: org.apache.kafka.common.serialization.StringSerializer       value-serializer: org.springframework.kafka.support.serializer.JsonSerializer       bootstrap-servers: localhost:9092       properties:         spring:           json:             add:               type:                 headers: false

UserDTO,java
@Data @NoArgsConstructor public class UserDTO {     private String name;     private String surname;     private String email;     private LocalDateTime time = LocalDateTime.now();       public UserDTO(String name, String surname, String email) {         this.name = name;         this.surname = surname;         this.email = email;     } }

Base64Service

Представленный код представляет интерфейс Base64Service и его имплементацию, который используется для работы с кодированием и декодированием данных.

public interface Base64Service {      <T> T decode(String data, Class<T> to);      <T> String encode(T t);  }
@Service public class Base64ServiceImpl implements Base64Service {       private final ObjectMapper objectMapper;      public Base64ServiceImpl(             @Qualifier("customObjectMapper") ObjectMapper objectMapper     ) {         this.objectMapper = objectMapper;     }      @Override     public <T> T decode(String data, Class<T> to) {         try {              byte[] decodedBytes = Base64.getDecoder().decode(data);              String jsonData = new String(decodedBytes);              return objectMapper.readValue(jsonData,to);          } catch (Exception e) {             throw new Base64OperationException("Failed to decode or convert the data", e);         }     }      @Override     public <T> String encode(T t) {         String jsonData = null;         try {             jsonData = objectMapper.writeValueAsString(t);         } catch (JsonProcessingException e) {             throw new Base64OperationException(e.getMessage());         }         return Base64.getEncoder().encodeToString(jsonData.getBytes());     } }

KafkaProducer

Kafka Producerопределяет метод для отправки сообщений с использованием KafkaTemplate.

public interface KafkaProducer {     <T> void produce(String topic, T t); }
@Component @Slf4j @RequiredArgsConstructor public class DefaultKafkaProducer implements KafkaProducer {      private final KafkaTemplate<String, Object> kafkaTemplate;      @Override     public <T> void produce(String topic, T t) {         kafkaTemplate.send(                 topic, t         ).whenComplete((res, th) -> {             log.info("produced message: " + res.getProducerRecord() + " topic: " + res.getProducerRecord().topic());         });     }  }

UserContoller
@RequiredArgsConstructor @RestController @CrossOrigin(origins = "*") public class UserController {      private final UserService userService;      @PostMapping("register")     ResponseEntity<?> requestToRegistration(             @RequestBody UserDTO userDTO     ) {         return ResponseEntity                 .ok(userService.requestToRegistration(userDTO));     }      @PostMapping("confirm-registration")     ResponseEntity<?> confirm(             @RequestParam String data     ) {         return ResponseEntity                 .status(201)                 .body(userService.confirmRegistration(data));     }   }

UserService
public interface UserService {      StatusResponse requestToRegistration(UserDTO userDTO);      User confirmRegistration(String hash); }
@Service @RequiredArgsConstructor public class UserServiceImpl implements UserService {      private final UserRepository userRepository;     private final Base64Service base64;     private final KafkaProducer kafkaProducer;      public static final String EMAIL_TOPIC = "email_message";      @Override     public StatusResponse requestToRegistration(UserDTO userDTO) {         try {             var optionalUser = this.userRepository.findByEmail(userDTO.getEmail());              if (optionalUser.isPresent()) {                 throw new EmailRegisteredException("email: %s registered yet".formatted(userDTO.getEmail()));             }              var dataToSend = base64.encode(userDTO);              kafkaProducer.produce(EMAIL_TOPIC, new KafkaEmailMessageDTO(userDTO.getEmail(), dataToSend));              return new StatusResponse(                     true, null             );         } catch (Exception e) {             return new StatusResponse(                     false,                     e.getMessage()             );         }     }      @Override     public User confirmRegistration(String hash) {          var userDTO = base64.decode(hash, UserDTO.class);          if (userDTO.getTime().isBefore(LocalDateTime.now().minusDays(1))) {             throw new LinkExpiredException();         }          var user = new User(                 userDTO.getName(),                 userDTO.getSurname(),                 userDTO.getEmail()         );          return this.userRepository.save(user);     }   }

Если попытаться перейти по данной ссылке через 24 часа после её отправки, она не будет валидной.

Mail Service

Перед реализацией этого сервиса нужно будет получить credentials. Полный гайд.

application.yml
spring:   mail:     host: smtp.gmail.com     port: 587     username: ${SMTP_USERNAME}     password: ${SMTP_PASSWORD}     properties:       mail:         smtp:           auth: true         smtp.starttls.enable: true   kafka:     consumer:       bootstrap-servers: localhost:9092       key-deserializer: org.apache.kafka.common.serialization.StringDeserializer       value-deserializer: org.apache.kafka.common.serialization.ByteArrayDeserializer       properties:         spring:           json:             add:               type:                 headers: false server:   frontend-url: http://localhost:4200

MailListener
@Component @Slf4j @RequiredArgsConstructor public class MailListener {      private final MailService mailService;      @KafkaListener(             topics = "email_message", groupId = "some"     )     void listen(             KafkaMailMessage kafkaMailMessage     ) {         log.info("email message: {} ", kafkaMailMessage);         mailService.send(kafkaMailMessage, MessageMode.EMAIL_VERIFICATION);     } }

MailService
public interface MailService {     void send(KafkaMailMessage kafkaMailMessage, MessageMode mode); }
@Component @RequiredArgsConstructor @Slf4j public class MailServiceImpl implements MailService {      private final JavaMailSender mailSender;      @Value("${server.frontend-url}")     private String frontEndURL;      @Override     public void send(KafkaMailMessage kafkaMailMessage, MessageMode mode) {         var msg = new SimpleMailMessage();          if (mode == MessageMode.EMAIL_VERIFICATION) {             msg.setText(frontEndURL + "/verification?data=" + kafkaMailMessage.message());         } else {             msg.setText(kafkaMailMessage.message());         }          msg.setTo(kafkaMailMessage.email());         msg.setFrom("habrexample@gmail.com");          try {             mailSender.send(msg);             log.info("email send, msg: {}, mode: {}", kafkaMailMessage, mode);         } catch (Exception e) {             log.error("send mail error : {}", e.getMessage());         }       } }


Angular Client

Структура проекта выглядит таким образом:

Структура проекта выглядит таким образом:
Registration Component
@Component({   selector: 'app-registration',   templateUrl: './registration.component.html',   styleUrls: ['./registration.component.css'] }) export class RegistrationComponent {    registrationForm: FormGroup;    constructor(     private userService: UserService,     private formBuilder: FormBuilder   ) {     this.registrationForm = this.formBuilder.group({       name: ['', Validators.required],       surname: ['', Validators.required],       email: ['', [Validators.required, Validators.email]],     });   }    register() {     let name = this.findInRegistrationForm('name')     let surname = this.findInRegistrationForm('surname')     let email = this.findInRegistrationForm('email')      let userDTO = {name, surname, email}      this.userService.requestToRegistration(userDTO)       .subscribe((res: StatusResponse) => {           alert(JSON.stringify(res))         }       )   }    private findInRegistrationForm(     controlName: string   ) {     return this.registrationForm.get(controlName)?.value as string   }   }

Если вы заметили, мы отправляем ссылку в таком шаблоне: http://localhost:4200/verification?data={dataFromKafkaMailMessage}

Verification Component
import {Component, OnInit} from '@angular/core'; import {ActivatedRoute, Router} from "@angular/router"; import {User} from "../../model/User"; import {UserService} from "../../service/user.service";  @Component({   selector: 'app-verification',   templateUrl: './verification.component.html',   styleUrls: ['./verification.component.css'] }) export class VerificationComponent implements OnInit {    constructor(     private route: ActivatedRoute,     private userService: UserService   ) {   }    user: User    ngOnInit(): void {     this.route.queryParams.subscribe(params => {       let data = params['data'] || null;        if (data) {         this.userService.confirmRegistration(data)           .subscribe(res => {             this.user = res             console.log(res)           }, err => {             if (err) {               alert('invalid confirmation link');             }           })       } else {         alert('missing data')       }     })   }   }

Success означает успешное отправление сообщения в mail.

Выше скриншот, того как это выглядит в gmail. По клику мы автоматически переходим в verification component, после компонент извлекает данные с URL и отправляет через user service в бэкенд.

UserService
@Injectable({   providedIn: 'root' }) export class UserService {    private http = inject(HttpClient)    private BASE_URL = 'http://localhost:3000/api/v1/user';    requestToRegistration(     userDTO: UserDTO   ): Observable<any> {     return this.http       .post(`${this.BASE_URL}/register`, userDTO);   }    confirmRegistration(     data: string   ): Observable<any> {     return this.http       .post(`${this.BASE_URL}/confirm-registration?data=` + data, {});   }  }

Результат:

Заключение

Мы рассмотрели важный аспект безопасности — подтверждение почты пользователей в веб-приложениях. Таким образом, мы готовы обеспечить надежность и защиту данных пользователей.

Ссылка на Github.


ссылка на оригинал статьи https://habr.com/ru/articles/764458/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *