From 19198836286e5eb5c72bc3b3ca68d5a7aefd5a56 Mon Sep 17 00:00:00 2001 From: vladp Date: Mon, 24 Nov 2025 12:29:32 +0700 Subject: [PATCH] INC-1 add create user endpoint,generate tokens --- build.gradle | 1 + .../configuration/ApplicationConfig.java | 17 ++++ .../configuration/JpaConfiguration.java | 9 +++ .../no_copy/controller/AuthController.java | 22 ++++++ .../ru/soune/no_copy/dto/AuthResponse.java | 5 ++ .../ru/soune/no_copy/dto/RegisterRequest.java | 13 ++++ .../ru/soune/no_copy/entity/AuthToken.java | 27 ++++++- .../java/ru/soune/no_copy/entity/User.java | 51 ++++++++++-- .../soune/no_copy/exception/ApiException.java | 4 - .../exception/UserAlreadyExistsException.java | 7 ++ .../handler/GlobalExceptionHandler.java | 43 ++++++++++ .../repository/AuthTokenRepository.java | 4 + .../no_copy/repository/UserRepository.java | 5 ++ .../ru/soune/no_copy/service/AuthService.java | 53 +++++++++++++ .../soune/no_copy/service/TokenService.java | 7 -- src/main/resources/meassge.properties | 6 ++ .../no_copy/service/AuthTestService.java | 78 +++++++++++++++++++ 17 files changed, 332 insertions(+), 20 deletions(-) create mode 100644 src/main/java/ru/soune/no_copy/configuration/ApplicationConfig.java create mode 100644 src/main/java/ru/soune/no_copy/configuration/JpaConfiguration.java create mode 100644 src/main/java/ru/soune/no_copy/dto/AuthResponse.java create mode 100644 src/main/java/ru/soune/no_copy/dto/RegisterRequest.java delete mode 100644 src/main/java/ru/soune/no_copy/exception/ApiException.java create mode 100644 src/main/java/ru/soune/no_copy/exception/UserAlreadyExistsException.java create mode 100644 src/main/java/ru/soune/no_copy/handler/GlobalExceptionHandler.java delete mode 100644 src/main/java/ru/soune/no_copy/service/TokenService.java create mode 100644 src/main/resources/meassge.properties create mode 100644 src/test/java/ru/soune/no_copy/service/AuthTestService.java diff --git a/build.gradle b/build.gradle index 5101285..6af1b68 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.security:spring-security-crypto:6.5.3' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc' diff --git a/src/main/java/ru/soune/no_copy/configuration/ApplicationConfig.java b/src/main/java/ru/soune/no_copy/configuration/ApplicationConfig.java new file mode 100644 index 0000000..8b0409b --- /dev/null +++ b/src/main/java/ru/soune/no_copy/configuration/ApplicationConfig.java @@ -0,0 +1,17 @@ +package ru.soune.no_copy.configuration; + +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +@EnableAutoConfiguration +public class ApplicationConfig { + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/ru/soune/no_copy/configuration/JpaConfiguration.java b/src/main/java/ru/soune/no_copy/configuration/JpaConfiguration.java new file mode 100644 index 0000000..56ae18b --- /dev/null +++ b/src/main/java/ru/soune/no_copy/configuration/JpaConfiguration.java @@ -0,0 +1,9 @@ +package ru.soune.no_copy.configuration; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfiguration { +} diff --git a/src/main/java/ru/soune/no_copy/controller/AuthController.java b/src/main/java/ru/soune/no_copy/controller/AuthController.java index 149c1d9..143659f 100644 --- a/src/main/java/ru/soune/no_copy/controller/AuthController.java +++ b/src/main/java/ru/soune/no_copy/controller/AuthController.java @@ -1,7 +1,29 @@ package ru.soune.no_copy.controller; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import ru.soune.no_copy.dto.AuthResponse; +import ru.soune.no_copy.dto.RegisterRequest; +import ru.soune.no_copy.entity.AuthToken; +import ru.soune.no_copy.service.AuthService; @RestController +@RequestMapping("/v1/api/auth") +@RequiredArgsConstructor public class AuthController { + + private final AuthService authService; + + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody RegisterRequest registerRequest) { + AuthToken authToken = authService.register(registerRequest); + + return ResponseEntity.ok(new AuthResponse(true, "success.user.register", authToken.getToken(), + authToken.getExpiresAt())); + } } diff --git a/src/main/java/ru/soune/no_copy/dto/AuthResponse.java b/src/main/java/ru/soune/no_copy/dto/AuthResponse.java new file mode 100644 index 0000000..8e76c1a --- /dev/null +++ b/src/main/java/ru/soune/no_copy/dto/AuthResponse.java @@ -0,0 +1,5 @@ +package ru.soune.no_copy.dto; + +import java.time.LocalDateTime; + +public record AuthResponse (boolean success, String message, String token, LocalDateTime expiresAt) {} diff --git a/src/main/java/ru/soune/no_copy/dto/RegisterRequest.java b/src/main/java/ru/soune/no_copy/dto/RegisterRequest.java new file mode 100644 index 0000000..3f4fe6e --- /dev/null +++ b/src/main/java/ru/soune/no_copy/dto/RegisterRequest.java @@ -0,0 +1,13 @@ +package ru.soune.no_copy.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record RegisterRequest( + @NotBlank(message = "error.name.length") @Size(min = 2, max = 64) String firstName, + @NotBlank(message = "error.name.length") @Size(min = 2, max = 64) String secondName, + @NotBlank(message = "error.name.length") @Size(min = 2, max = 64) String lastName, + @NotBlank(message = "error.not.blank") @Email(message = "error.not.email") @Size(max = 128)String email, + @NotBlank(message = "error.not.blank") @Size(min = 8) String password +) {} diff --git a/src/main/java/ru/soune/no_copy/entity/AuthToken.java b/src/main/java/ru/soune/no_copy/entity/AuthToken.java index 5568fab..ee78fe9 100644 --- a/src/main/java/ru/soune/no_copy/entity/AuthToken.java +++ b/src/main/java/ru/soune/no_copy/entity/AuthToken.java @@ -1,12 +1,37 @@ package ru.soune.no_copy.entity; -import jakarta.persistence.Entity; +import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; @Entity @NoArgsConstructor @Getter +@Setter +@EntityListeners(AuditingEntityListener.class) +@Table(name = "auth_tokens") public class AuthToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 64) + private String token; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt = LocalDateTime.now().plusDays(30); + + @CreatedDate + @Column(name = "created_at", updatable = false, nullable = false) + private LocalDateTime createdAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; } diff --git a/src/main/java/ru/soune/no_copy/entity/User.java b/src/main/java/ru/soune/no_copy/entity/User.java index 49ada2b..808c70f 100644 --- a/src/main/java/ru/soune/no_copy/entity/User.java +++ b/src/main/java/ru/soune/no_copy/entity/User.java @@ -1,23 +1,58 @@ package ru.soune.no_copy.entity; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import jakarta.validation.constraints.Size; import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @AllArgsConstructor @NoArgsConstructor @ToString +@EqualsAndHashCode @Getter @Setter +@EntityListeners(AuditingEntityListener.class) +@Table(name = "users") public class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long userId; - public void setUserId(Long userId) { - this.userId = userId; - } + @Size(max = 64) + @Column(name = "firstName", nullable = false, length = 64) + private String firstName; + + @Size(max = 64) + @Column(name = "lastName", nullable = false, length = 64) + private String lastName; + + @Size(max = 64) + @Column(name = "secondName", length = 64) + private String secondName; + + @Size(max = 1024) + @Column(name = "email", nullable = false, length = 1024, unique = true) + private String email; + + @Column(nullable = false) + @JsonIgnore + private String password; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List tokens = new ArrayList<>(); + + @CreatedDate + @Column(name = "created_at", updatable = false, nullable = false) + private LocalDateTime createdAt; + + @Column(name = "is_active") + private Boolean isActive; - public Long getUserId() { - return userId; - } } diff --git a/src/main/java/ru/soune/no_copy/exception/ApiException.java b/src/main/java/ru/soune/no_copy/exception/ApiException.java deleted file mode 100644 index a2e0059..0000000 --- a/src/main/java/ru/soune/no_copy/exception/ApiException.java +++ /dev/null @@ -1,4 +0,0 @@ -package ru.soune.no_copy.exception; - -public class ApiException extends RuntimeException { -} diff --git a/src/main/java/ru/soune/no_copy/exception/UserAlreadyExistsException.java b/src/main/java/ru/soune/no_copy/exception/UserAlreadyExistsException.java new file mode 100644 index 0000000..8815885 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/exception/UserAlreadyExistsException.java @@ -0,0 +1,7 @@ +package ru.soune.no_copy.exception; + +public class UserAlreadyExistsException extends RuntimeException { + public UserAlreadyExistsException(String message) { + super(message); + } +} diff --git a/src/main/java/ru/soune/no_copy/handler/GlobalExceptionHandler.java b/src/main/java/ru/soune/no_copy/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..0380ccc --- /dev/null +++ b/src/main/java/ru/soune/no_copy/handler/GlobalExceptionHandler.java @@ -0,0 +1,43 @@ +package ru.soune.no_copy.handler; + +import lombok.AllArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import ru.soune.no_copy.exception.UserAlreadyExistsException; + +import java.util.Map; + +@RestControllerAdvice +@AllArgsConstructor +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .findFirst() + .orElse(""); + + return ResponseEntity.badRequest() + .body(Map.of( + "success", false, + "message" ,message)); + } + + @ExceptionHandler(UserAlreadyExistsException.class) + @ResponseStatus(HttpStatus.CONFLICT) + public ResponseEntity handleUserContainsException(UserAlreadyExistsException ex) { + return ResponseEntity. + badRequest() + .body(Map.of( + "success", false, + "message" ,ex.getMessage() + )); + } +} diff --git a/src/main/java/ru/soune/no_copy/repository/AuthTokenRepository.java b/src/main/java/ru/soune/no_copy/repository/AuthTokenRepository.java index 990b787..725d8e0 100644 --- a/src/main/java/ru/soune/no_copy/repository/AuthTokenRepository.java +++ b/src/main/java/ru/soune/no_copy/repository/AuthTokenRepository.java @@ -4,6 +4,10 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import ru.soune.no_copy.entity.AuthToken; +import java.time.LocalDate; +import java.util.Optional; + @Repository public interface AuthTokenRepository extends JpaRepository { + Optional findByTokenAndExpiresAtAfter(String token, LocalDate expiresAtAfter); } diff --git a/src/main/java/ru/soune/no_copy/repository/UserRepository.java b/src/main/java/ru/soune/no_copy/repository/UserRepository.java index bfd1b20..b5aba82 100644 --- a/src/main/java/ru/soune/no_copy/repository/UserRepository.java +++ b/src/main/java/ru/soune/no_copy/repository/UserRepository.java @@ -3,5 +3,10 @@ package ru.soune.no_copy.repository; import org.springframework.data.jpa.repository.JpaRepository; import ru.soune.no_copy.entity.User; +import java.util.Optional; + public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + + boolean existsByEmail(String email); } diff --git a/src/main/java/ru/soune/no_copy/service/AuthService.java b/src/main/java/ru/soune/no_copy/service/AuthService.java index 1051025..968a777 100644 --- a/src/main/java/ru/soune/no_copy/service/AuthService.java +++ b/src/main/java/ru/soune/no_copy/service/AuthService.java @@ -1,7 +1,60 @@ package ru.soune.no_copy.service; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.soune.no_copy.dto.RegisterRequest; +import ru.soune.no_copy.entity.AuthToken; +import ru.soune.no_copy.entity.User; +import ru.soune.no_copy.exception.UserAlreadyExistsException; +import ru.soune.no_copy.repository.AuthTokenRepository; +import ru.soune.no_copy.repository.UserRepository; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.Locale; @Service +@RequiredArgsConstructor public class AuthService { + + private final UserRepository userRepository; + + private final AuthTokenRepository authTokenRepository; + + private final PasswordEncoder passwordEncoder; + + private final MessageSource messageSource; + + @Transactional + public AuthToken register(RegisterRequest registerRequest) { + if (userRepository.existsByEmail(registerRequest.email())) { + throw new UserAlreadyExistsException(messageSource.getMessage("error.user.exists", null, + Locale.getDefault())); + } + + User user = new User(); + user.setFirstName(registerRequest.firstName()); + user.setSecondName(registerRequest.secondName()); + user.setLastName(registerRequest.lastName()); + user.setEmail(registerRequest.email()); + user.setPassword(passwordEncoder.encode(registerRequest.password())); + + User savedUser = userRepository.save(user); + + AuthToken authToken = new AuthToken(); + authToken.setToken(generateToken()); + authToken.setUser(savedUser); + + return authTokenRepository.save(authToken); + } + + private String generateToken() { + byte[] bytes = new byte[32]; + new SecureRandom().nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } } diff --git a/src/main/java/ru/soune/no_copy/service/TokenService.java b/src/main/java/ru/soune/no_copy/service/TokenService.java deleted file mode 100644 index 81f83e1..0000000 --- a/src/main/java/ru/soune/no_copy/service/TokenService.java +++ /dev/null @@ -1,7 +0,0 @@ -package ru.soune.no_copy.service; - -import org.springframework.stereotype.Service; - -@Service -public class TokenService { -} diff --git a/src/main/resources/meassge.properties b/src/main/resources/meassge.properties new file mode 100644 index 0000000..9a212bf --- /dev/null +++ b/src/main/resources/meassge.properties @@ -0,0 +1,6 @@ +error.user.exists=User exist with current email. +error.name.length=Value mast be length 2 and max 64 symbols. +error.not.email=Not email format +error.not.blank=Must not be empty + +success.user.register=User was register \ No newline at end of file diff --git a/src/test/java/ru/soune/no_copy/service/AuthTestService.java b/src/test/java/ru/soune/no_copy/service/AuthTestService.java new file mode 100644 index 0000000..e5109c4 --- /dev/null +++ b/src/test/java/ru/soune/no_copy/service/AuthTestService.java @@ -0,0 +1,78 @@ +package ru.soune.no_copy.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.context.MessageSource; +import org.springframework.security.crypto.password.PasswordEncoder; +import ru.soune.no_copy.dto.RegisterRequest; +import ru.soune.no_copy.entity.AuthToken; +import ru.soune.no_copy.entity.User; +import ru.soune.no_copy.exception.UserAlreadyExistsException; +import ru.soune.no_copy.repository.AuthTokenRepository; +import ru.soune.no_copy.repository.UserRepository; + +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +public class AuthTestService { + + @Mock + private UserRepository userRepository; + + @Mock + private AuthTokenRepository authTokenRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private MessageSource messageSource; + + @InjectMocks + private AuthService authService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void registerSuccess() { + RegisterRequest request = new RegisterRequest("John","A.","Doe","john@example.com","password123"); + + when(userRepository.existsByEmail(request.email())).thenReturn(false); + when(passwordEncoder.encode(request.password())).thenReturn("hashed"); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(authTokenRepository.save(any(AuthToken.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + AuthToken token = authService.register(request); + User user = token.getUser(); + String email = user.getEmail(); + + assertNotNull(token); + assertNotNull(token.getToken()); + assertNotNull(token.getUser()); + assertEquals("john@example.com", email); + assertNotNull(token.getExpiresAt()); + } + + @Test + void registerExistingEmailThrows() { + RegisterRequest request = new RegisterRequest("John","A.","Doe","john@example.com","123"); + + when(userRepository.existsByEmail(request.email())).thenReturn(true); + when(messageSource.getMessage(anyString(), any(), any(Locale.class))).thenReturn("User exists"); + + UserAlreadyExistsException ex = assertThrows(UserAlreadyExistsException.class, + () -> authService.register(request)); + + assertEquals("User exists", ex.getMessage()); + } +}