diff --git a/src/main/java/ru/soune/nocopy/configuration/HandlerConfig.java b/src/main/java/ru/soune/nocopy/configuration/HandlerConfig.java index 69bbe52..0ef41c7 100644 --- a/src/main/java/ru/soune/nocopy/configuration/HandlerConfig.java +++ b/src/main/java/ru/soune/nocopy/configuration/HandlerConfig.java @@ -19,7 +19,8 @@ public class HandlerConfig { LogoutRequestHandler logoutHandler, ImageFoundRequestHandler imageFoundRequestHandler, VerifyRegisterUserHandler verifyRegisterUser, - AuthRequestHandler authRequestHandler + AuthRequestHandler authRequestHandler, + ResetPasswordHandler resetPasswordHandler ) { Map 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; } diff --git a/src/main/java/ru/soune/nocopy/dto/MessageCode.java b/src/main/java/ru/soune/nocopy/dto/MessageCode.java index 6070af8..6306e88 100644 --- a/src/main/java/ru/soune/nocopy/dto/MessageCode.java +++ b/src/main/java/ru/soune/nocopy/dto/MessageCode.java @@ -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; diff --git a/src/main/java/ru/soune/nocopy/dto/register/ResetPasswordRequest.java b/src/main/java/ru/soune/nocopy/dto/register/ResetPasswordRequest.java new file mode 100644 index 0000000..caaeb83 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/register/ResetPasswordRequest.java @@ -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; +} diff --git a/src/main/java/ru/soune/nocopy/dto/register/ResetPasswordResponse.java b/src/main/java/ru/soune/nocopy/dto/register/ResetPasswordResponse.java new file mode 100644 index 0000000..b3d7122 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/register/ResetPasswordResponse.java @@ -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; +} diff --git a/src/main/java/ru/soune/nocopy/entity/user/User.java b/src/main/java/ru/soune/nocopy/entity/user/User.java index c61d15d..84ae7b1 100644 --- a/src/main/java/ru/soune/nocopy/entity/user/User.java +++ b/src/main/java/ru/soune/nocopy/entity/user/User.java @@ -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 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 imageProtections = new ArrayList<>(); + + @Column(name = "failed_login_attempts") + @ColumnDefault("0") + private Integer failedLoginAttempts = 0; } diff --git a/src/main/java/ru/soune/nocopy/entity/user/UserNotActive.java b/src/main/java/ru/soune/nocopy/entity/user/UserNotActive.java new file mode 100644 index 0000000..e586ade --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/user/UserNotActive.java @@ -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; +} diff --git a/src/main/java/ru/soune/nocopy/handler/LoginRequestHandler.java b/src/main/java/ru/soune/nocopy/handler/LoginRequestHandler.java index 27bfd0f..6271328 100644 --- a/src/main/java/ru/soune/nocopy/handler/LoginRequestHandler.java +++ b/src/main/java/ru/soune/nocopy/handler/LoginRequestHandler.java @@ -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()))); diff --git a/src/main/java/ru/soune/nocopy/handler/ResetPasswordHandler.java b/src/main/java/ru/soune/nocopy/handler/ResetPasswordHandler.java new file mode 100644 index 0000000..2d44e70 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/handler/ResetPasswordHandler.java @@ -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 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); + } +} diff --git a/src/main/java/ru/soune/nocopy/handler/VerifyRegisterUserHandler.java b/src/main/java/ru/soune/nocopy/handler/VerifyRegisterUserHandler.java index 00a1e10..3f38a23 100644 --- a/src/main/java/ru/soune/nocopy/handler/VerifyRegisterUserHandler.java +++ b/src/main/java/ru/soune/nocopy/handler/VerifyRegisterUserHandler.java @@ -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 userAuthTokens = authTokenRepository.findByUserId(userId); + List 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); diff --git a/src/main/java/ru/soune/nocopy/repository/AuthTokenRepository.java b/src/main/java/ru/soune/nocopy/repository/AuthTokenRepository.java index 08b2ee5..5a0b2e3 100644 --- a/src/main/java/ru/soune/nocopy/repository/AuthTokenRepository.java +++ b/src/main/java/ru/soune/nocopy/repository/AuthTokenRepository.java @@ -14,5 +14,5 @@ public interface AuthTokenRepository extends JpaRepository { Optional findByLastUsedAtBefore(LocalDateTime lastUsedAt); Optional findByToken(String token); Optional findByUserIdAndIsActive(Long userId, boolean isActive); - Optional findByUserId(Long userId); + List findByUserId(Long userId); } diff --git a/src/main/java/ru/soune/nocopy/repository/UserNotActiveRepository.java b/src/main/java/ru/soune/nocopy/repository/UserNotActiveRepository.java new file mode 100644 index 0000000..e3fad73 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/repository/UserNotActiveRepository.java @@ -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 { + Optional findByUserId(Long userId); + List findByBlockedUntilBefore(LocalDateTime now); + +} diff --git a/src/main/java/ru/soune/nocopy/service/auth/CleanUpNotActiveUser.java b/src/main/java/ru/soune/nocopy/service/auth/CleanUpNotActiveUser.java new file mode 100644 index 0000000..2fea9c7 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/auth/CleanUpNotActiveUser.java @@ -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 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); + + } + } +} diff --git a/src/main/java/ru/soune/nocopy/service/mail/EmailService.java b/src/main/java/ru/soune/nocopy/service/mail/EmailService.java index cc3b1cb..5ef3cc6 100644 --- a/src/main/java/ru/soune/nocopy/service/mail/EmailService.java +++ b/src/main/java/ru/soune/nocopy/service/mail/EmailService.java @@ -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); diff --git a/src/main/java/ru/soune/nocopy/service/register/AuthService.java b/src/main/java/ru/soune/nocopy/service/register/AuthService.java index 1038e63..193d234 100644 --- a/src/main/java/ru/soune/nocopy/service/register/AuthService.java +++ b/src/main/java/ru/soune/nocopy/service/register/AuthService.java @@ -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 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); + } + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 171cca5..9ec954e 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -83,5 +83,6 @@ app: email: verification: standart-subject: NO COPY - Подтверждение email адреса + drop-password-subject: NO COPY - сброс пароля standart-address-from: NO COPY user: Пользователь \ No newline at end of file diff --git a/src/main/resources/templates/reset-password.html b/src/main/resources/templates/reset-password.html new file mode 100644 index 0000000..abc80ec --- /dev/null +++ b/src/main/resources/templates/reset-password.html @@ -0,0 +1,201 @@ + + + + + + Восстановление пароля + + + +
+
+ +

Восстановление пароля

+

Безопасный доступ к вашему аккаунту

+
+ +
+

+ Здравствуйте, {{username}}! +

+ +

Мы получили запрос на восстановление пароля для вашего аккаунта NO COPY.

+

Для продолжения восстановления доступа, пожалуйста, используйте следующий код подтверждения:

+ +
+

{{code}}

+

Код для восстановления пароля

+
+ +
+

Код действителен в течение 15 минут

+
+ +

Как использовать этот код:

+
    +
  1. Вернитесь на страницу восстановления пароля
  2. +
  3. Введите полученный код в поле подтверждения
  4. +
  5. Создайте новый надежный пароль
  6. +
  7. Войдите в аккаунт с новым паролем
  8. +
+ + + + + +
+ Важная информация:
+ 1. Если вы не запрашивали восстановление пароля, проигнорируйте это письмо.
+ 2. Никому не сообщайте этот код, включая сотрудников поддержки.
+ 3. После успешного восстановления старый пароль будет недействителен. +
+ +

+ Совет по безопасности: Используйте пароль длиной не менее 8 символов, + содержащий буквы (заглавные и строчные), цифры и специальные символы. +

+
+ + +
+ + \ No newline at end of file