NCBACK-23 add full logic to reset
Test Workflow / test (push) Successful in 3s

This commit is contained in:
vladp
2026-01-22 03:39:49 +07:00
parent d1b5b6eb5c
commit a982eef9c6
16 changed files with 535 additions and 13 deletions
@@ -19,7 +19,8 @@ public class HandlerConfig {
LogoutRequestHandler logoutHandler,
ImageFoundRequestHandler imageFoundRequestHandler,
VerifyRegisterUserHandler verifyRegisterUser,
AuthRequestHandler authRequestHandler
AuthRequestHandler authRequestHandler,
ResetPasswordHandler resetPasswordHandler
) {
Map<Integer, RequestHandler> map = new HashMap<>();
map.put(20001, login);
@@ -30,6 +31,7 @@ public class HandlerConfig {
map.put(20007, imageFoundRequestHandler);
map.put(20008, authRequestHandler);
map.put(20009, verifyRegisterUser);
map.put(20010, resetPasswordHandler);
return map;
}
@@ -24,7 +24,8 @@ public enum MessageCode {
AUTH_TOKEN_MISMATCH(4, "Token mismatch"),
AUTH_TOKEN_NOT_FOUND(4, "Token not found"),
FILE_NOT_FOUND(4, "File not found"),
AUTH_PASSWORD_NOT_MATCHES(2, "Password does not match");
AUTH_PASSWORD_NOT_MATCHES(2, "Password does not match"),
USER_NOT_ACTIVE(2, "User not active");
private final Integer code;
@@ -0,0 +1,24 @@
package ru.soune.nocopy.dto.register;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ResetPasswordRequest {
@JsonProperty("email")
private String email;
@JsonProperty("verifyToken")
private String verifyToken;
@JsonProperty("action")
private String action;
@JsonProperty("password")
private String password;
}
@@ -0,0 +1,13 @@
package ru.soune.nocopy.dto.register;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class ResetPasswordResponse {
@JsonProperty("user_id")
private Long userId;
@JsonProperty("authToken")
private String authToken;
}
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import jakarta.validation.constraints.Size;
import lombok.*;
import org.hibernate.annotations.ColumnDefault;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import ru.soune.nocopy.entity.file.ImageProtection;
@@ -76,6 +77,11 @@ public class User {
@ToString.Exclude
private List<AuthToken> tokens = new ArrayList<>();
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonIgnore
@ToString.Exclude
private UserNotActive userNotActive;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonIgnore
@ToString.Exclude
@@ -90,4 +96,8 @@ public class User {
@JsonIgnore
@ToString.Exclude
private List<ImageProtection> imageProtections = new ArrayList<>();
@Column(name = "failed_login_attempts")
@ColumnDefault("0")
private Integer failedLoginAttempts = 0;
}
@@ -0,0 +1,32 @@
package ru.soune.nocopy.entity.user;
import jakarta.persistence.*;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import java.time.LocalDateTime;
@Entity
@Table(name = "user_not_active")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class UserNotActive {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(fetch = FetchType.LAZY)
private User user;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "blocked_until")
private LocalDateTime blockedUntil;
}
@@ -14,7 +14,6 @@ import ru.soune.nocopy.service.register.AuthService;
import java.util.Arrays;
import java.util.Map;
import java.util.Optional;
@Component
@RequiredArgsConstructor
@@ -31,7 +30,7 @@ public class LoginRequestHandler implements RequestHandler {
LoginRequest loginRequest = objectMapper.convertValue(request.getMessageBody(), LoginRequest.class);
User user = userRepository.findByEmail(loginRequest.getEmail());
if (user != null) {
if (user == null) {
LoginAnswer loginAnswer = new LoginAnswer();
loginAnswer.setFieldErrors(Arrays.asList(Map.of("email", loginRequest.getEmail())));
@@ -0,0 +1,112 @@
package ru.soune.nocopy.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import ru.soune.nocopy.dto.BaseRequest;
import ru.soune.nocopy.dto.BaseResponse;
import ru.soune.nocopy.dto.MessageCode;
import ru.soune.nocopy.dto.file.ActionResponse;
import ru.soune.nocopy.dto.register.ResetPasswordRequest;
import ru.soune.nocopy.dto.register.ResetPasswordResponse;
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.exception.NotValidFieldException;
import ru.soune.nocopy.exception.UserNotFoundException;
import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.repository.EmailVerificationTokenRepository;
import ru.soune.nocopy.repository.UserRepository;
import ru.soune.nocopy.service.mail.EmailService;
import ru.soune.nocopy.service.register.AuthService;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Component
@Slf4j
@RequiredArgsConstructor
public class ResetPasswordHandler implements RequestHandler{
private final ObjectMapper objectMapper;
private final EmailService emailService;
private final AuthService authService;
private final UserRepository userRepository;
private final EmailVerificationTokenRepository emailVerificationTokenRepository;
private final AuthTokenRepository authTokenRepository;
private final PasswordEncoder passwordEncoder;
@Override
public BaseResponse handle(BaseRequest request) throws Exception {
ResetPasswordRequest resetPasswordRequest = objectMapper.convertValue(request.getMessageBody(),
ResetPasswordRequest.class);
String action = resetPasswordRequest.getAction();
User user = userRepository.findByEmail(resetPasswordRequest.getEmail());
AuthToken authToken;
if (user == null) {
throw new UserNotFoundException("User not found with email: " + resetPasswordRequest.getEmail());
}
switch (action) {
case "resetPassword":
authToken = authService.generateAuthToken(user);
EmailVerificationToken emailToken = emailService.createEmailVerificationToken(authToken);
emailService.sendResetPasswordEmail(authToken.getUser(), emailToken.getToken());
return successResponse(request, authToken, user, MessageCode.SUCCESS.getDescription());
case "confirmVerification":
String verifyToken = resetPasswordRequest.getVerifyToken();
EmailVerificationToken verificationToken =
emailVerificationTokenRepository.findByUserIdAndToken(user.getId(), verifyToken);
if (verificationToken == null) {
throw new NotValidFieldException("Verification token not found",
new BaseResponse(request.getMsgId(), MessageCode.INVALID_TOKEN.getCode(),
MessageCode.INVALID_TOKEN.getDescription(), Map.of("verifyToken", verifyToken)));
}
List<AuthToken> authTokens = authTokenRepository.findByUserId(user.getId());
authToken = authTokens.isEmpty() ? authService.generateAuthToken(user): authTokens.getFirst();
user.setPassword(passwordEncoder.encode(resetPasswordRequest.getPassword()));
user.setActive(true);
emailVerificationTokenRepository.delete(verificationToken);
User saveUser = userRepository.save(user);
return successResponse(request, authToken, saveUser, MessageCode.SUCCESS.getDescription());
default:
ActionResponse response = ActionResponse.builder()
.action(action)
.availableActions(Arrays.asList("confirmVerification", "resetPassword"))
.build();
return new BaseResponse(request.getMsgId(), MessageCode.INVALID_ACTION.getCode(),
"Invalid action: " + action, response);
}
}
private BaseResponse successResponse(BaseRequest request, AuthToken authToken, User user, String message) {
ResetPasswordResponse resetPasswordResponse = new ResetPasswordResponse();
resetPasswordResponse.setAuthToken(authToken.getToken());
resetPasswordResponse.setUserId(user.getId());
return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), message, resetPasswordResponse);
}
}
@@ -21,6 +21,7 @@ import ru.soune.nocopy.service.mail.EmailService;
import ru.soune.nocopy.service.register.AuthService;
import ru.soune.nocopy.service.user.UserService;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -51,9 +52,9 @@ public class VerifyRegisterUserHandler implements RequestHandler {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("User not found with id: " + userId));
Optional<AuthToken> userAuthTokens = authTokenRepository.findByUserId(userId);
List<AuthToken> authTokens = authTokenRepository.findByUserId(user.getId());
AuthToken authToken = userAuthTokens.isEmpty() ? authService.generateAuthToken(user): userAuthTokens.get();
AuthToken authToken = authTokens.isEmpty() ? authService.generateAuthToken(user): authTokens.getFirst();
if (verifyUserRequest.getResend() != null && verifyUserRequest.getResend() == 1) {
return handleResend(request, user, authToken);
@@ -14,5 +14,5 @@ public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
Optional<AuthToken> findByLastUsedAtBefore(LocalDateTime lastUsedAt);
Optional<AuthToken> findByToken(String token);
Optional<AuthToken> findByUserIdAndIsActive(Long userId, boolean isActive);
Optional<AuthToken> findByUserId(Long userId);
List<AuthToken> findByUserId(Long userId);
}
@@ -0,0 +1,16 @@
package ru.soune.nocopy.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.soune.nocopy.entity.user.UserNotActive;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Repository
public interface UserNotActiveRepository extends JpaRepository<UserNotActive, Long> {
Optional<UserNotActive> findByUserId(Long userId);
List<UserNotActive> findByBlockedUntilBefore(LocalDateTime now);
}
@@ -0,0 +1,48 @@
package ru.soune.nocopy.service.auth;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.entity.user.UserNotActive;
import ru.soune.nocopy.repository.UserNotActiveRepository;
import ru.soune.nocopy.repository.UserRepository;
import java.time.LocalDateTime;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class CleanUpNotActiveUser {
@Autowired
private UserNotActiveRepository userNotActiveRepository;
@Autowired
private UserRepository userRepository;
@Transactional
@Scheduled(fixedDelay = 600000)
public void cleanupNotActiveUsers() {
LocalDateTime now = LocalDateTime.now();
List<UserNotActive> expiredTokens = userNotActiveRepository.findByBlockedUntilBefore(now);
for (UserNotActive userNotActive : expiredTokens) {
User user = userNotActive.getUser();
user.setActive(true);
user.setFailedLoginAttempts(0);
userRepository.save(user);
}
if (!expiredTokens.isEmpty()) {
userNotActiveRepository.deleteAll(expiredTokens);
}
}
}
@@ -31,6 +31,9 @@ public class EmailService {
@Value("${app.email.verification.standart-subject}")
private String standartSubject;
@Value("${app.email.verification.drop-password-subject}")
private String dropPasswordSubject;
@Value("${app.email.verification.standart-address-from}")
private String standartAdressFrom;
@@ -46,7 +49,8 @@ public class EmailService {
helper.setTo(user.getEmail());
helper.setSubject(standartSubject);
String htmlContent = loadAndProcessTemplate(user.getFullName(), code);
String htmlContent = loadAndProcessTemplate(user.getFullName(), code,
"templates/email-verification.html");
helper.setText(htmlContent, true);
mailSender.send(message);
@@ -58,6 +62,27 @@ public class EmailService {
}
}
public void sendResetPasswordEmail(User user, String code) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(standartAdressFrom);
helper.setTo(user.getEmail());
helper.setSubject(dropPasswordSubject);
String htmlContent = loadAndProcessTemplate(user.getFullName(), code,
"templates/reset-password.html");
helper.setText(htmlContent, true);
mailSender.send(message);
log.info("Email sent to: {}", user.getEmail());
} catch (Exception e) {
log.error("Failed to send email: {}", e.getMessage());
}
}
@Transactional
public EmailVerificationToken createEmailVerificationToken(AuthToken authToken) {
EmailVerificationToken emailToken = new EmailVerificationToken();
@@ -75,8 +100,8 @@ public class EmailService {
return String.valueOf(code);
}
private String loadAndProcessTemplate(String username, String code) throws IOException {
ClassPathResource resource = new ClassPathResource("templates/email-verification.html");
private String loadAndProcessTemplate(String username, String code, String templatePath) throws IOException {
ClassPathResource resource = new ClassPathResource(templatePath);
String template = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
String safeUsername = escapeHtml(username != null ? username : user);
String safeCode = escapeHtml(code);
@@ -11,9 +11,11 @@ import ru.soune.nocopy.dto.register.LoginRequest;
import ru.soune.nocopy.dto.register.RegRequest;
import ru.soune.nocopy.entity.user.AuthToken;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.entity.user.UserNotActive;
import ru.soune.nocopy.exception.NotFoundAuthToken;
import ru.soune.nocopy.exception.NotValidFieldException;
import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.repository.UserNotActiveRepository;
import ru.soune.nocopy.repository.UserRepository;
import java.security.SecureRandom;
@@ -30,7 +32,7 @@ public class AuthService {
private final PasswordEncoder passwordEncoder;
private final MessageSource messageSource;
private final UserNotActiveRepository userNotActiveRepository;
private final SecureRandom secureRandom = new SecureRandom();
@@ -76,14 +78,30 @@ public class AuthService {
return authToken.getUser().getId();
}
@Transactional
public AuthToken login(LoginRequest request) {
User user = userRepository.findByEmail(request.getEmail());
if (!user.isActive()) {
throw new NotValidFieldException("User not active", new BaseResponse(20003,
MessageCode.USER_NOT_ACTIVE.getCode(),
MessageCode.USER_NOT_ACTIVE.getDescription(), Map.of("userId", user.getId())));
}
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
LoginAnswer loginAnswer = new LoginAnswer();
loginAnswer.setFieldErrors(Arrays.asList(Map.of("password", request.getPassword())));
loginAnswer.setVerified(user.isEmailVerified());
loginAnswer.setActive(user.isActive());
int attempts = user.getFailedLoginAttempts() + 1;
user.setFailedLoginAttempts(attempts);
if (attempts >= 5) {
user.setActive(false);
blockUser(user);
}
userRepository.save(user);
throw new NotValidFieldException("Invalid password", new BaseResponse(20003,
MessageCode.AUTH_PASSWORD_NOT_MATCHES.getCode(),
@@ -91,6 +109,9 @@ public class AuthService {
}
user.setLastLoginAt(LocalDateTime.now());
user.setFailedLoginAttempts(0);
user.setActive(true);
User savedUser = userRepository.save(user);
AuthToken authToken = new AuthToken();
@@ -106,6 +127,7 @@ public class AuthService {
.ifPresent(authTokenRepository::delete);
}
@Transactional
public AuthToken generateAuthToken(User user) {
return authTokenRepository.save(genereateAuthToken(user));
}
@@ -123,4 +145,19 @@ public class AuthService {
secureRandom.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
public void blockUser(User user) {
Optional<UserNotActive> existing = userNotActiveRepository.findByUserId(user.getId());
if (existing.isEmpty()) {
LocalDateTime now = LocalDateTime.now();
UserNotActive notActive = UserNotActive.builder()
.user(user)
.createdAt(now)
.blockedUntil(now.plusMinutes(15))
.build();
userNotActiveRepository.save(notActive);
}
}
}
+1
View File
@@ -83,5 +83,6 @@ app:
email:
verification:
standart-subject: NO COPY - Подтверждение email адреса
drop-password-subject: NO COPY - сброс пароля
standart-address-from: NO COPY <noreply@nocopy.com>
user: Пользователь
@@ -0,0 +1,201 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Восстановление пароля</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, #ff6b6b 0%, #ee5a52 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: #ee5a52;
font-weight: bold;
}
.code-container {
background: #f8f9fa;
border: 2px dashed #ff6b6b;
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;
}
.action-button {
display: inline-block;
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
color: white;
padding: 14px 32px;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.action-button:hover {
background: linear-gradient(135deg, #ee5a52 0%, #d64541 100%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(238, 90, 82, 0.3);
}
.steps {
margin: 30px 0;
padding-left: 20px;
}
.steps li {
margin-bottom: 10px;
padding-left: 5px;
}
.footer {
text-align: center;
padding: 25px;
color: #666;
font-size: 12px;
border-top: 1px solid #eee;
background: #f9f9f9;
}
.highlight {
color: #ee5a52;
font-weight: 600;
}
.urgent {
color: #d63031;
font-weight: 700;
}
.link-fallback {
margin-top: 15px;
font-size: 13px;
color: #666;
}
.timer {
background: #ffebee;
border-left: 4px solid #ff6b6b;
padding: 12px 15px;
margin: 20px 0;
border-radius: 0 8px 8px 0;
}
.timer-text {
color: #d32f2f;
font-weight: 600;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">NO COPY</div>
<h1>Восстановление пароля</h1>
<p>Безопасный доступ к вашему аккаунту</p>
</div>
<div class="content">
<p class="greeting">
Здравствуйте, <span class="username">{{username}}</span>!
</p>
<p>Мы получили запрос на восстановление пароля для вашего аккаунта NO COPY.</p>
<p>Для продолжения восстановления доступа, пожалуйста, используйте следующий код подтверждения:</p>
<div class="code-container">
<p class="verification-code">{{code}}</p>
<p class="info-text">Код для восстановления пароля</p>
</div>
<div class="timer">
<p class="timer-text">Код действителен в течение 15 минут</p>
</div>
<h3>Как использовать этот код:</h3>
<ol class="steps">
<li>Вернитесь на страницу восстановления пароля</li>
<li>Введите полученный код в поле подтверждения</li>
<li>Создайте новый надежный пароль</li>
<li>Войдите в аккаунт с новым паролем</li>
</ol>
<div style="text-align: center;">
<a href="{{resetLink}}" class="action-button">Перейти к восстановлению пароля</a>
</div>
<p class="link-fallback">
Если кнопка не работает, скопируйте и вставьте эту ссылку в браузер:<br>
<span style="color: #3498db; word-break: break-all;">{{resetLink}}</span>
</p>
<div class="warning">
<strong>Важная информация:</strong><br>
1. Если вы не запрашивали восстановление пароля, проигнорируйте это письмо.<br>
2. Никому не сообщайте этот код, включая сотрудников поддержки.<br>
3. После успешного восстановления старый пароль будет недействителен.
</div>
<p style="font-size: 13px; color: #7f8c8d;">
<strong>Совет по безопасности:</strong> Используйте пароль длиной не менее 8 символов,
содержащий буквы (заглавные и строчные), цифры и специальные символы.
</p>
</div>
<div class="footer">
<p>© 2024 NO COPY. Все права защищены.</p>
<p>Это письмо отправлено автоматически, пожалуйста, не отвечайте на него.</p>
<p>Если у вас возникли вопросы или вы не запрашивали восстановление пароля,
<a href="mailto:support@nocopy.com" style="color: #ee5a52;">свяжитесь со службой поддержки</a>.</p>
<p style="font-size: 11px; margin-top: 10px;">
IP запроса: {{ipAddress}} • Время: {{timestamp}} • Устройство: {{deviceInfo}}
</p>
</div>
</div>
</body>
</html>