Всем привет! Мы с вами поговорим о важном аспекте безопасности — подтверждении почты пользователей. Мы расскажем, как сделать это с использованием 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.
