NCBACK-33 add start logic for verify by token
Test Workflow / test (push) Successful in 4s

This commit is contained in:
vladp
2026-01-16 20:20:14 +07:00
parent 0ed3f958ff
commit 4704f780e5
31 changed files with 333 additions and 34 deletions
@@ -19,7 +19,7 @@ import ru.soune.nocopy.dto.file.ChunkUploadResponse;
import ru.soune.nocopy.dto.file.CompleteUploadResponse;
import ru.soune.nocopy.dto.file.FileEntityResponse;
import ru.soune.nocopy.dto.file.UploadProgress;
import ru.soune.nocopy.entity.AuthToken;
import ru.soune.nocopy.entity.user.AuthToken;
import ru.soune.nocopy.entity.file.FileStatus;
import ru.soune.nocopy.entity.file.UploadStatus;
import ru.soune.nocopy.exception.*;
@@ -6,8 +6,8 @@ import org.springframework.web.bind.annotation.*;
import ru.soune.nocopy.dto.ChangePasswordRequest;
import ru.soune.nocopy.dto.UserDTO;
import ru.soune.nocopy.dto.UserRequest;
import ru.soune.nocopy.entity.AuthToken;
import ru.soune.nocopy.entity.User;
import ru.soune.nocopy.entity.user.AuthToken;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.exception.InvalidUserEmail;
import ru.soune.nocopy.exception.NotFoundAuthToken;
import ru.soune.nocopy.mapper.UserMapper;
@@ -4,7 +4,7 @@ import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import ru.soune.nocopy.entity.GenderType;
import ru.soune.nocopy.entity.user.GenderType;
import ru.soune.nocopy.entity.SubscriptionType;
import java.time.LocalDate;
@@ -5,7 +5,7 @@ import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Past;
import jakarta.validation.constraints.Size;
import lombok.Data;
import ru.soune.nocopy.entity.GenderType;
import ru.soune.nocopy.entity.user.GenderType;
import java.time.LocalDate;
@@ -4,6 +4,8 @@ import jakarta.persistence.*;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.entity.user.UserContent;
import java.time.LocalDateTime;
@@ -4,6 +4,7 @@ import jakarta.persistence.*;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import ru.soune.nocopy.entity.user.User;
import java.time.LocalDateTime;
@@ -1,4 +1,4 @@
package ru.soune.nocopy.entity;
package ru.soune.nocopy.entity.user;
import jakarta.persistence.*;
import lombok.*;
@@ -0,0 +1,31 @@
package ru.soune.nocopy.entity.user;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "email_verification_tokens")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class EmailVerificationToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "token", nullable = false, length = 6)
private String token;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
}
@@ -1,4 +1,4 @@
package ru.soune.nocopy.entity;
package ru.soune.nocopy.entity.user;
public enum GenderType {
MALE, FEMALE
@@ -1,4 +1,4 @@
package ru.soune.nocopy.entity;
package ru.soune.nocopy.entity.user;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
@@ -6,6 +6,9 @@ import jakarta.validation.constraints.Size;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import ru.soune.nocopy.entity.ImageProtection;
import ru.soune.nocopy.entity.SubscriptionType;
import ru.soune.nocopy.entity.Violation;
import java.time.LocalDate;
import java.time.LocalDateTime;
@@ -33,6 +36,9 @@ public class User {
@Column(name = "email", nullable = false, length = 1024, unique = true)
private String email;
@Column(name = "email_verified", nullable = false)
private Boolean emailVerified = false;
@Column(name = "company")
private String company;
@@ -64,7 +70,7 @@ public class User {
private LocalDateTime lastLoginAt;
@Column(name = "is_active")
private Boolean isActive = true;
private Boolean isActive = false;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonIgnore
@@ -1,4 +1,4 @@
package ru.soune.nocopy.entity;
package ru.soune.nocopy.entity.user;
import jakarta.persistence.*;
import lombok.*;
@@ -1,9 +1,10 @@
package ru.soune.nocopy.entity;
package ru.soune.nocopy.entity.user;
import jakarta.persistence.*;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import ru.soune.nocopy.entity.ContentStatus;
import ru.soune.nocopy.entity.file.FileType;
import java.time.LocalDateTime;
@@ -6,7 +6,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import ru.soune.nocopy.dto.*;
import ru.soune.nocopy.dto.file.*;
import ru.soune.nocopy.entity.AuthToken;
import ru.soune.nocopy.entity.user.AuthToken;
import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.entity.file.FileStatus;
import ru.soune.nocopy.exception.FileEntityNotFoundException;
@@ -10,7 +10,7 @@ import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import ru.soune.nocopy.dto.*;
import ru.soune.nocopy.dto.file.*;
import ru.soune.nocopy.entity.AuthToken;
import ru.soune.nocopy.entity.user.AuthToken;
import ru.soune.nocopy.entity.file.FileType;
import ru.soune.nocopy.entity.file.FileUploadSession;
import ru.soune.nocopy.exception.*;
@@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import ru.soune.nocopy.dto.*;
import ru.soune.nocopy.entity.AuthToken;
import ru.soune.nocopy.entity.user.AuthToken;
import ru.soune.nocopy.exception.NotValidFieldException;
import ru.soune.nocopy.repository.UserRepository;
import ru.soune.nocopy.service.AuthService;
@@ -4,8 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import ru.soune.nocopy.dto.*;
import ru.soune.nocopy.entity.AuthToken;
import ru.soune.nocopy.entity.User;
import ru.soune.nocopy.entity.user.AuthToken;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.exception.NotValidFieldException;
import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.repository.UserRepository;
@@ -7,15 +7,20 @@ import org.springframework.stereotype.Component;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.BindingResult;
import ru.soune.nocopy.dto.*;
import ru.soune.nocopy.entity.AuthToken;
import ru.soune.nocopy.entity.user.AuthToken;
import ru.soune.nocopy.entity.user.EmailVerificationToken;
import ru.soune.nocopy.exception.NotValidFieldException;
import ru.soune.nocopy.exception.ValidationException;
import ru.soune.nocopy.handler.validator.RegRequestValidator;
import ru.soune.nocopy.repository.EmailVerificationTokenRepository;
import ru.soune.nocopy.repository.UserRepository;
import ru.soune.nocopy.service.AuthService;
import ru.soune.nocopy.service.EmailService;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Map;
import java.util.UUID;
@Slf4j
@Component
@@ -29,6 +34,10 @@ public class RegRequestHandler implements RequestHandler {
private final ObjectMapper objectMapper;
private final EmailService emailService;
private final EmailVerificationTokenRepository emailVerificationTokenRepository;
@Override
public BaseResponse handle(BaseRequest request) throws ValidationException {
RegRequest regRequest = objectMapper.convertValue(request.getMessageBody(), RegRequest.class);
@@ -45,6 +54,7 @@ public class RegRequestHandler implements RequestHandler {
}
BindingResult bindingResult = new BeanPropertyBindingResult(regRequest, "regRequest");
regRequestValidator.validate(regRequest, bindingResult);
if (bindingResult.hasErrors()) {
@@ -52,6 +62,9 @@ public class RegRequestHandler implements RequestHandler {
}
AuthToken authToken = authService.register(regRequest);
EmailVerificationToken emailToken = emailService.createEmailVerificationToken(authToken);
emailService.sendVerificationEmail(authToken.getUser(), emailToken.getToken());
RegAnswer regAnswer = new RegAnswer();
regAnswer.setToken(authToken.getToken());
@@ -5,7 +5,7 @@ import org.mapstruct.MappingTarget;
import org.mapstruct.NullValuePropertyMappingStrategy;
import org.mapstruct.factory.Mappers;
import ru.soune.nocopy.dto.UserContentUpdateRequest;
import ru.soune.nocopy.entity.UserContent;
import ru.soune.nocopy.entity.user.UserContent;
@Mapper(componentModel = "spring",
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
@@ -4,7 +4,7 @@ import org.mapstruct.Mapper;
import org.mapstruct.NullValuePropertyMappingStrategy;
import org.mapstruct.factory.Mappers;
import ru.soune.nocopy.dto.UserDTO;
import ru.soune.nocopy.entity.User;
import ru.soune.nocopy.entity.user.User;
@Mapper(componentModel = "spring",
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
@@ -2,7 +2,7 @@ package ru.soune.nocopy.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.soune.nocopy.entity.AuthToken;
import ru.soune.nocopy.entity.user.AuthToken;
import java.time.LocalDate;
import java.util.Optional;
@@ -0,0 +1,10 @@
package ru.soune.nocopy.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import ru.soune.nocopy.entity.user.EmailVerificationToken;
import java.util.Optional;
public interface EmailVerificationTokenRepository extends JpaRepository<EmailVerificationToken, Long> {
Optional<EmailVerificationToken> findByToken(String token);
}
@@ -3,7 +3,7 @@ package ru.soune.nocopy.repository;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import ru.soune.nocopy.entity.file.FileType;
import ru.soune.nocopy.entity.UserContent;
import ru.soune.nocopy.entity.user.UserContent;
import java.util.List;
@@ -2,7 +2,7 @@ package ru.soune.nocopy.repository;
import jakarta.validation.constraints.Size;
import org.springframework.data.jpa.repository.JpaRepository;
import ru.soune.nocopy.entity.User;
import ru.soune.nocopy.entity.user.User;
import java.util.Optional;
@@ -6,18 +6,15 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.dto.*;
import ru.soune.nocopy.entity.AuthToken;
import ru.soune.nocopy.entity.User;
import ru.soune.nocopy.entity.user.AuthToken;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.exception.NotValidFieldException;
import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.repository.UserRepository;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Base64;
import java.util.Map;
import java.util.Optional;
import java.util.*;
@Service
@RequiredArgsConstructor
@@ -0,0 +1,89 @@
package ru.soune.nocopy.service;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.entity.user.AuthToken;
import ru.soune.nocopy.entity.user.EmailVerificationToken;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.repository.EmailVerificationTokenRepository;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
@Slf4j
public class EmailService {
private final JavaMailSender mailSender;
private final EmailVerificationTokenRepository emailVerificationTokenRepository;
public void sendVerificationEmail(User user, String code) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom("NO COPY <noreply@nocopy.com>");
helper.setTo(user.getEmail());
helper.setSubject("NO COPY - Подтверждение email адреса");
String htmlContent = loadAndProcessTemplate(user.getFullName(), code);
helper.setText(htmlContent, true);
mailSender.send(message);
} catch (MessagingException | IOException e) {
log.error("Fail send email: " + e.getMessage());
}
}
@Transactional
public EmailVerificationToken createEmailVerificationToken(AuthToken authToken) {
EmailVerificationToken emailToken = new EmailVerificationToken();
emailToken.setUser(authToken.getUser());
emailToken.setToken(generateVerificationCode());
emailToken.setExpiresAt(LocalDateTime.now().plusMinutes(15));
emailToken.setCreatedAt(LocalDateTime.now());
return emailVerificationTokenRepository.save(emailToken);
}
private String generateVerificationCode() {
SecureRandom random = new SecureRandom();
int code = 100000 + random.nextInt(900000);
return String.valueOf(code);
}
private String loadAndProcessTemplate(String username, String code) throws IOException {
ClassPathResource resource = new ClassPathResource("templates/email-verification.html");
String template = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
String safeUsername = escapeHtml(username != null ? username : "Пользователь");
String safeCode = escapeHtml(code);
return template
.replace("{{username}}", safeUsername)
.replace("{{code}}", safeCode);
}
private String escapeHtml(String text) {
if (text == null) return "";
return text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
}
@@ -6,8 +6,8 @@ import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.dto.UserContentRequest;
import ru.soune.nocopy.dto.UserContentUpdateRequest;
import ru.soune.nocopy.entity.file.FileType;
import ru.soune.nocopy.entity.User;
import ru.soune.nocopy.entity.UserContent;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.entity.user.UserContent;
import ru.soune.nocopy.exception.ContentNotFoundException;
import ru.soune.nocopy.exception.UserNotFoundException;
import ru.soune.nocopy.mapper.UserContentMapper;
@@ -7,7 +7,7 @@ import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.dto.ChangePasswordRequest;
import ru.soune.nocopy.dto.UserDTO;
import ru.soune.nocopy.dto.UserRequest;
import ru.soune.nocopy.entity.User;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.exception.InvalidUserEmail;
import ru.soune.nocopy.exception.NotValidationPasswordException;
import ru.soune.nocopy.exception.UserNotFoundException;
+22 -1
View File
@@ -1,4 +1,16 @@
spring:
mail:
host: smtp.gmail.com
port: 587
username: vlad.popovtsev@gmail.com
password: aylgtjcxdtjvohrk
properties:
mail:
smtp:
auth: true
starttls:
enable: true
datasource:
url: jdbc:postgresql://${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
username: ${POSTGRES_USER}
@@ -56,4 +68,13 @@ logging:
yandex:
api-key: ${YANDEX_API_KEY:AQVNyaVaUmgUb1GMCtf5zSEqFxy0woXrcMOOB43q}
folder-id: ${YANDEX_FOLDER_ID:b1gokpdbm6qfpsou8pcd}
search-url: ${YANDEX_SEARCH_URL:https://searchapi.api.cloud.yandex.net/v2/image/search_by_image}
search-url: ${YANDEX_SEARCH_URL:https://searchapi.api.cloud.yandex.net/v2/image/search_by_image}
app:
email:
verification:
expiration-hours: ${EMAIL_VERIFICATION_EXPIRATION:24}
base-url: ${EMAIL_BASE_URL:http://localhost:80}
from-email: ${EMAIL_FROM:noreply@yourdomain.com}
templates:
verification: verification-email.html
@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Подтверждение email</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.container {
max-width: 600px;
margin: 0 auto;
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 6px 20px rgba(0,0,0,0.1);
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px 20px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 800;
margin-bottom: 10px;
letter-spacing: 1px;
}
.content {
padding: 40px 30px;
}
.greeting {
font-size: 18px;
margin-bottom: 20px;
}
.username {
color: #667eea;
font-weight: bold;
}
.code-container {
background: #f8f9fa;
border: 2px dashed #667eea;
border-radius: 10px;
padding: 25px;
text-align: center;
margin: 30px 0;
}
.verification-code {
font-size: 42px;
font-weight: 800;
letter-spacing: 8px;
color: #333;
margin: 0;
}
.info-text {
color: #666;
font-size: 14px;
margin: 10px 0;
}
.warning {
background: #fff3cd;
border: 1px solid #ffeaa7;
color: #856404;
padding: 15px;
border-radius: 8px;
margin: 25px 0;
font-size: 14px;
}
.footer {
text-align: center;
padding: 25px;
color: #666;
font-size: 12px;
border-top: 1px solid #eee;
background: #f9f9f9;
}
.highlight {
color: #764ba2;
font-weight: 600;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">NO COPY</div>
<h1>Подтверждение email адреса</h1>
</div>
<div class="content">
<p class="greeting">
Здравствуйте, <span class="username">{{username}}</span>!
</p>
<p>Спасибо за регистрацию на платформе NO COPY.
Для завершения регистрации, пожалуйста, введите код подтверждения:</p>
<div class="code-container">
<p class="verification-code">{{code}}</p>
<p class="info-text">Код подтверждения</p>
</div>
<p class="info-text">
Код действителен в течение <span class="highlight">15 минут</span>
</p>
<div class="warning">
<strong>Важно:</strong> Если вы не регистрировались на NO COPY,
просто проигнорируйте это письмо.
</div>
</div>
<div class="footer">
<p>© 2024 NO COPY. Все права защищены.</p>
<p>Это письмо отправлено автоматически, пожалуйста, не отвечайте на него.</p>
<p>Если у вас возникли вопросы, обратитесь в службу поддержки.</p>
</div>
</div>
</body>
</html>