Подтверждение электронной почты с помощью 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/


Интересные статьи

Интересные статьи

Привет! Меня зовут Алексей Салаев, я Java-разработчик команды Corp Digital в Росбанке. В этом посте я расскажу, как можно оптимизировать и кастомизировать запросы в Spring: опишу потенциальные проблем...
Эта статья расскажет вам, как я начал бороться с клинической депрессией, используя для этого свое любимое занятие - программирование на c++. Это моя первая статья из цикла статей, который я решил назв...
Рассказываем про опыт участия команды Napoleon IT под кодовым названием Night-stress-testing в хакатоне "Цифровой прорыв" и решение кейса от республики Тыва по детекции источников лесных пожаров. ...
Недавно автор узнал об инструменте csvquote, который кодирует проблемные символы CSV так, чтобы утилиты unix правильно их обрабатывали. Он меняет кодировку в конце конвейера, восстанавливая исходный в...
В предыдущей статье мы остановились на варианте, который с помощью SWAR-хинта превращает 8 последовательных цифр в одно числовое 32bit-значение. Но что если мы предположи...