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

Комментарии (2)


  1. Nurked
    30.09.2023 03:58

    Удивительно, что только люди не придумают, чтобы не посылать письма вручную.


  1. LightSouls
    30.09.2023 03:58
    +2

    А что мешает пользователю самому закодировать дто и подтвердить не свою почту ?