diff --git a/backend/smartjam-api/build.gradle b/backend/smartjam-api/build.gradle index e49f2f8..0b6206b 100644 --- a/backend/smartjam-api/build.gradle +++ b/backend/smartjam-api/build.gradle @@ -1,15 +1,23 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + implementation "org.springframework.boot:spring-boot-starter-validation" + implementation 'org.springframework.boot:spring-boot-starter-security' + + implementation 'io.jsonwebtoken:jjwt-api:0.13.0' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0' + + implementation 'org.mapstruct:mapstruct:1.5.5.Final' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - runtimeOnly 'org.postgresql:postgresql' + + runtimeOnly('org.postgresql:postgresql') implementation 'org.springframework.boot:spring-boot-starter-kafka' testImplementation 'org.springframework.kafka:spring-kafka-test' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2' - - implementation project(':smartjam-common') + implementation("software.amazon.awssdk:s3:2.29.22") } \ No newline at end of file diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/config/ApplicationConfig.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/config/ApplicationConfig.java new file mode 100644 index 0000000..d674051 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/config/ApplicationConfig.java @@ -0,0 +1,25 @@ +package com.smartjam.smartjamapi.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +@RequiredArgsConstructor +public class ApplicationConfig { + + private final CustomUserDetailsService customUserDetailsService; + + @Bean + public UserDetailsService userDetailsService() { + return customUserDetailsService::loadUserByUsername; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/config/CustomUserDetailsService.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/config/CustomUserDetailsService.java new file mode 100644 index 0000000..5400e9b --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/config/CustomUserDetailsService.java @@ -0,0 +1,27 @@ +package com.smartjam.smartjamapi.config; + +import jakarta.validation.constraints.NotBlank; + +import com.smartjam.smartjamapi.entity.UserEntity; +import com.smartjam.smartjamapi.repository.UserRepository; +import com.smartjam.smartjamapi.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetailsImpl loadUserByUsername(@NotBlank String email) throws UsernameNotFoundException { + UserEntity user = userRepository + .findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("Invalid credentials")); + + return UserDetailsImpl.build(user); + } +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/config/SecurityConfig.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/config/SecurityConfig.java new file mode 100644 index 0000000..07351fa --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/config/SecurityConfig.java @@ -0,0 +1,35 @@ +package com.smartjam.smartjamapi.config; + +import com.smartjam.smartjamapi.security.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth.requestMatchers("/api/auth/**") + .permitAll() + .anyRequest() + .authenticated()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/controller/AuthController.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/controller/AuthController.java new file mode 100644 index 0000000..103ce1e --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/controller/AuthController.java @@ -0,0 +1,40 @@ +package com.smartjam.smartjamapi.controller; + +import jakarta.validation.Valid; + +import com.smartjam.smartjamapi.dto.AuthResponse; +import com.smartjam.smartjamapi.dto.LoginRequest; +import com.smartjam.smartjamapi.dto.RefreshTokenRequest; +import com.smartjam.smartjamapi.dto.RegisterRequest; +import com.smartjam.smartjamapi.service.AuthService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@Slf4j +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/login") + public ResponseEntity login(@RequestBody @Valid LoginRequest request) { + log.info("Calling login"); + return ResponseEntity.ok(authService.login(request)); + } + + @PostMapping("/register") + public ResponseEntity register(@RequestBody @Valid RegisterRequest request) { + log.info("Calling register"); + return ResponseEntity.status(201).body(authService.register(request)); + } + + @PostMapping("/refresh") + public ResponseEntity getNewTokens(@RequestBody @Valid RefreshTokenRequest refreshTokenRequest) { + log.info("Calling getNewToken"); + return ResponseEntity.ok(authService.getNewTokens(refreshTokenRequest)); + } +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/controller/MainController.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/controller/MainController.java new file mode 100644 index 0000000..282efb9 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/controller/MainController.java @@ -0,0 +1,28 @@ +package com.smartjam.smartjamapi.controller; + +import java.security.Principal; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/secured") +@Slf4j +public class MainController { + + @GetMapping("/user") + public String userAccess(Principal principal) { + log.info(principal.getName()); + + log.info("userAccess called for: {}", principal.getName()); + return principal.getName(); + } + + @GetMapping("/hello") + // @PreAuthorize() + public String hello() { + return "You are auth"; + } +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/controller/UploadController.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/controller/UploadController.java new file mode 100644 index 0000000..3026563 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/controller/UploadController.java @@ -0,0 +1,24 @@ +package com.smartjam.smartjamapi.controller; + +import jakarta.validation.Valid; + +import com.smartjam.smartjamapi.dto.UploadRequest; +import com.smartjam.smartjamapi.dto.UploadUrlResponse; +import com.smartjam.smartjamapi.service.UploadService; +import lombok.AllArgsConstructor; +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.RestController; + +@RestController +@AllArgsConstructor +public class UploadController { + + private final UploadService uploadService; + + @PostMapping("/upload-url") + public ResponseEntity getUploadUrl(@RequestBody @Valid UploadRequest request) { + return ResponseEntity.ok(uploadService.generateUploadUrl(request.fileName())); + } +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/AuthResponse.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/AuthResponse.java new file mode 100644 index 0000000..852d4b7 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/AuthResponse.java @@ -0,0 +1,7 @@ +package com.smartjam.smartjamapi.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record AuthResponse( + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("access_token") String accessToken) {} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/ErrorResponseDto.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/ErrorResponseDto.java new file mode 100644 index 0000000..a90110b --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/ErrorResponseDto.java @@ -0,0 +1,9 @@ +package com.smartjam.smartjamapi.dto; + +import java.time.LocalDateTime; + +import com.smartjam.smartjamapi.enums.ErrorCode; +import lombok.Builder; + +@Builder +public record ErrorResponseDto(ErrorCode code, String message, LocalDateTime errorTime) {} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/LoginRequest.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/LoginRequest.java new file mode 100644 index 0000000..7b94d88 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/LoginRequest.java @@ -0,0 +1,3 @@ +package com.smartjam.smartjamapi.dto; + +public record LoginRequest(String email, String password) {} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/RefreshTokenRequest.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/RefreshTokenRequest.java new file mode 100644 index 0000000..ec47839 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/RefreshTokenRequest.java @@ -0,0 +1,8 @@ +package com.smartjam.smartjamapi.dto; + +import jakarta.validation.constraints.NotBlank; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record RefreshTokenRequest( + @NotBlank @JsonProperty("refresh_token") String refreshToken) {} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/RegisterRequest.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/RegisterRequest.java new file mode 100644 index 0000000..c716a21 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/RegisterRequest.java @@ -0,0 +1,3 @@ +package com.smartjam.smartjamapi.dto; + +public record RegisterRequest(String email, String nickname, String password) {} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/UploadRequest.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/UploadRequest.java new file mode 100644 index 0000000..09b43a8 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/UploadRequest.java @@ -0,0 +1,5 @@ +package com.smartjam.smartjamapi.dto; + +import jakarta.validation.constraints.NotBlank; + +public record UploadRequest(@NotBlank String fileName) {} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/UploadUrlResponse.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/UploadUrlResponse.java new file mode 100644 index 0000000..e199325 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/dto/UploadUrlResponse.java @@ -0,0 +1,3 @@ +package com.smartjam.smartjamapi.dto; + +public record UploadUrlResponse(String uploadUrl) {} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/RefreshTokenEntity.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/RefreshTokenEntity.java new file mode 100644 index 0000000..e058918 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/RefreshTokenEntity.java @@ -0,0 +1,33 @@ +package com.smartjam.smartjamapi.entity; + +import java.time.Instant; +import java.util.UUID; + +import jakarta.persistence.*; + +import com.smartjam.smartjamapi.enums.StatusRefreshToken; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "refresh_tokens") +@Data +@NoArgsConstructor +public class RefreshTokenEntity { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, unique = true) + private String tokenHash; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserEntity user; + + @Column(nullable = false, name = "expires_at") + private Instant expiresAt; + + @Column(nullable = false) + private StatusRefreshToken status; +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/UserEntity.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/UserEntity.java new file mode 100644 index 0000000..56ff969 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/UserEntity.java @@ -0,0 +1,70 @@ +package com.smartjam.smartjamapi.entity; + +import java.time.Instant; +import java.util.UUID; + +import jakarta.persistence.*; + +import com.smartjam.smartjamapi.enums.Role; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +@Setter +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "users") +@Entity +public class UserEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false) + private String nickname; + + @Column(nullable = false, unique = true) + private String email; + + @Column(name = "password_hash", nullable = false) + private String passwordHash; + + @Column(name = "first_name") + private String firstName; + + @Column(name = "last_name") + private String lastName; + + @Column(name = "avatar_url") + private String avatarUrl; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role = Role.STUDENT; + + @Column(name = "fcm_token") + private String fcmToken; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + // @Override + // public @NonNull Collection getAuthorities() { + // return List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); + // } + // + // @Override + // public @NonNull String getPassword() { + // return passwordHash; + // } +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/AvailabilityStatus.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/AvailabilityStatus.java new file mode 100644 index 0000000..28f71b7 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/AvailabilityStatus.java @@ -0,0 +1,8 @@ +package com.smartjam.smartjamapi.enums; + +public enum AvailabilityStatus { + AVAILABLE, + UNAVAILABLE, + PENDING, + BANNED +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/ErrorCode.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/ErrorCode.java new file mode 100644 index 0000000..13bb24d --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/ErrorCode.java @@ -0,0 +1,9 @@ +package com.smartjam.smartjamapi.enums; + +public enum ErrorCode { + INTERNAL_SERVER_ERROR, + NOT_FOUND, + BAD_REQUEST, + UNAUTHORIZED, + RESOURCE_NOT_FOUND +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/Role.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/Role.java new file mode 100644 index 0000000..0e1a3f7 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/Role.java @@ -0,0 +1,6 @@ +package com.smartjam.smartjamapi.enums; + +public enum Role { + STUDENT, + TEACHER +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/StatusRefreshToken.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/StatusRefreshToken.java new file mode 100644 index 0000000..f7e9f5b --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/StatusRefreshToken.java @@ -0,0 +1,7 @@ +package com.smartjam.smartjamapi.enums; + +public enum StatusRefreshToken { + ACTIVE, + USED, + REVOKED +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/exception/GlobalExceptionHandler.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..5178dcf --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/exception/GlobalExceptionHandler.java @@ -0,0 +1,87 @@ +package com.smartjam.smartjamapi.exception; + +import java.time.LocalDateTime; +import java.util.NoSuchElementException; + +import jakarta.persistence.EntityNotFoundException; + +import com.smartjam.smartjamapi.dto.ErrorResponseDto; +import com.smartjam.smartjamapi.enums.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +// TODO: Это базовый шаблон для обработки ошибок +// TODO: Возможно, некоторые исключения ловятся неправильно +// TODO: Нужно будет потом уточнить маппинг (какое исключение -> какой статус) + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + private ResponseEntity buildResponse(HttpStatus status, ErrorCode errorCode, String message) { + + var dto = ErrorResponseDto.builder() + .code(errorCode) + .message(message) + .errorTime(LocalDateTime.now()) + .build(); + + return ResponseEntity.status(status).body(dto); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handlerGenericException(Exception e) { + log.error("Unexpected error: ", e); + + return buildResponse( + HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_SERVER_ERROR, "Internal server error"); + } + + @ExceptionHandler(UsernameNotFoundException.class) + public ResponseEntity handleUsernameNotFound(UsernameNotFoundException e) { + log.warn("Authentication failed"); + return buildResponse(HttpStatus.UNAUTHORIZED, ErrorCode.UNAUTHORIZED, "Invalid credentials"); + } + + @ExceptionHandler(NoSuchElementException.class) + public ResponseEntity handleNoSuchElement(NoSuchElementException e) { + log.warn("Resource not found: {}", e.getMessage()); + return buildResponse(HttpStatus.NOT_FOUND, ErrorCode.NOT_FOUND, "Requested resource not found"); + } + + @ExceptionHandler(EntityNotFoundException.class) + public ResponseEntity handlerEntityNotFound(EntityNotFoundException e) { + log.warn("Entity not found: ", e); + + return buildResponse(HttpStatus.NOT_FOUND, ErrorCode.NOT_FOUND, "Requested resource not found"); + } + + @ExceptionHandler({AuthenticationException.class, IllegalStateException.class}) + public ResponseEntity handleAuthException(AuthenticationException e) { + log.warn("Unauthenticated: {}", e.getMessage()); + + return buildResponse(HttpStatus.UNAUTHORIZED, ErrorCode.UNAUTHORIZED, "Unauthenticated"); + } + + @ExceptionHandler(exception = {IllegalArgumentException.class, MethodArgumentNotValidException.class}) + public ResponseEntity handlerBadRequest(Exception e) { + log.warn("Bad request: ", e); + + return buildResponse(HttpStatus.BAD_REQUEST, ErrorCode.BAD_REQUEST, "Invalid request data"); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleResourceNotFound(Exception e) { + log.warn("Resource not found: {}", e.getMessage()); + + return buildResponse( + HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "The requested resource was not found"); + } +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/exception/SecurityException.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/exception/SecurityException.java new file mode 100644 index 0000000..892140a --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/exception/SecurityException.java @@ -0,0 +1,9 @@ +package com.smartjam.smartjamapi.exception; + +import org.springframework.security.core.AuthenticationException; + +public class SecurityException extends AuthenticationException { + public SecurityException(String message) { + super(message); + } +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/exception/TokenExpiredException.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/exception/TokenExpiredException.java new file mode 100644 index 0000000..3aabce1 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/exception/TokenExpiredException.java @@ -0,0 +1,9 @@ +package com.smartjam.smartjamapi.exception; + +import org.springframework.security.core.AuthenticationException; + +public class TokenExpiredException extends AuthenticationException { + public TokenExpiredException(String message) { + super(message); + } +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/exception/TokenNotFoundException.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/exception/TokenNotFoundException.java new file mode 100644 index 0000000..b08ff73 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/exception/TokenNotFoundException.java @@ -0,0 +1,9 @@ +package com.smartjam.smartjamapi.exception; + +import org.springframework.security.core.AuthenticationException; + +public class TokenNotFoundException extends AuthenticationException { + public TokenNotFoundException(String message) { + super(message); + } +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/mapper/UserMapper.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/mapper/UserMapper.java new file mode 100644 index 0000000..7a3853a --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/mapper/UserMapper.java @@ -0,0 +1,10 @@ +package com.smartjam.smartjamapi.mapper; + +import com.smartjam.smartjamapi.dto.RegisterRequest; +import com.smartjam.smartjamapi.entity.UserEntity; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface UserMapper { + UserEntity toEntity(RegisterRequest request); +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/RefreshTokenRepository.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..3718ae0 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/RefreshTokenRepository.java @@ -0,0 +1,35 @@ +package com.smartjam.smartjamapi.repository; + +import java.util.Optional; +import java.util.UUID; + +import com.smartjam.smartjamapi.entity.RefreshTokenEntity; +import com.smartjam.smartjamapi.entity.UserEntity; +import com.smartjam.smartjamapi.enums.StatusRefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByTokenHash(String token); + + @Transactional + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE RefreshTokenEntity r SET r.status = :status WHERE r.tokenHash = :tokenHash") + void setStatusByRefreshToken(@Param("tokenHash") String tokenHash, @Param("status") StatusRefreshToken status); + + @Modifying + @Transactional + @Query(""" + UPDATE RefreshTokenEntity r + SET r.status = :newStatus + WHERE r.user = :user + AND r.status = :currentStatus +""") + void setStatusUsedRefreshToken( + @Param("user") UserEntity userEntity, + @Param("currentStatus") StatusRefreshToken currentStatus, + @Param("newStatus") StatusRefreshToken newStatus); +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/UserRepository.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/UserRepository.java new file mode 100644 index 0000000..3546fb6 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/UserRepository.java @@ -0,0 +1,14 @@ +package com.smartjam.smartjamapi.repository; + +import java.util.Optional; +import java.util.UUID; + +import com.smartjam.smartjamapi.entity.UserEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + + Optional findByEmail(String login); + + boolean existsByEmail(String email); +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/security/JwtAuthenticationFilter.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..f37cb92 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/security/JwtAuthenticationFilter.java @@ -0,0 +1,60 @@ +package com.smartjam.smartjamapi.security; + +import java.io.IOException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import com.smartjam.smartjamapi.config.CustomUserDetailsService; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final CustomUserDetailsService customUserDetailsService; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) + throws ServletException, IOException { + + final String authHeader = request.getHeader("Authorization"); + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + final String jwt = authHeader.substring(7); + final String email = jwtService.extractUsername(jwt); + + log.debug("Filter for {}", email); + if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetailsImpl userDetails = customUserDetailsService.loadUserByUsername(email); + + if (jwtService.isTokenValid(jwt, userDetails)) { + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + filterChain.doFilter(request, response); + } +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/security/JwtService.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/security/JwtService.java new file mode 100644 index 0000000..cd50c1f --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/security/JwtService.java @@ -0,0 +1,80 @@ +package com.smartjam.smartjamapi.security; + +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import javax.crypto.SecretKey; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Service; + +@Service +public class JwtService { + + @Value("${security.jwt.secret-key}") + private String secretKey; + + @Getter + @Value("${security.jwt.expiration-time-access}") + private long jwtExpiration; + + private Key getSigningKey() { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + return Keys.hmacShaKeyFor(keyBytes); + } + + public String generateAccessToken(UserDetailsImpl userDetails) { + Map claims = new HashMap<>(); + claims.put( + "authorities", + userDetails.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .toList()); + + return Jwts.builder() + .claims(claims) + .subject(userDetails.getEmail()) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + jwtExpiration)) + .signWith(getSigningKey()) + .compact(); + } + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + private Claims extractAllClaims(String token) { + return Jwts.parser() + .verifyWith((SecretKey) getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public boolean isTokenValid(String token, UserDetailsImpl userDetails) { + final String emailFromToken = extractUsername(token); + return emailFromToken.equals(userDetails.getEmail()) && !isTokenExpired(token); + } + + private boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + private Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/security/RefreshTokenService.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/security/RefreshTokenService.java new file mode 100644 index 0000000..c24d74d --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/security/RefreshTokenService.java @@ -0,0 +1,51 @@ +package com.smartjam.smartjamapi.security; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Base64; + +import com.smartjam.smartjamapi.entity.RefreshTokenEntity; +import com.smartjam.smartjamapi.entity.UserEntity; +import com.smartjam.smartjamapi.enums.StatusRefreshToken; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.codec.Hex; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class RefreshTokenService { + + @Getter + @Value("${security.jwt.expiration-time-refresh}") + private long refreshExpiration; + + public String generateRefreshToken() { + byte[] randomBytes = new byte[64]; + new SecureRandom().nextBytes(randomBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + } + + public static String hashRefreshToken(String token) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(token.getBytes(StandardCharsets.UTF_8)); + return new String(Hex.encode(digest)); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not found", e); + } + } + + public RefreshTokenEntity create(UserEntity userEntity, String refreshToken) { + RefreshTokenEntity refreshTokenEntity = new RefreshTokenEntity(); + refreshTokenEntity.setTokenHash(hashRefreshToken(refreshToken)); + refreshTokenEntity.setUser(userEntity); + refreshTokenEntity.setExpiresAt(Instant.now().plusMillis(getRefreshExpiration())); + refreshTokenEntity.setStatus(StatusRefreshToken.ACTIVE); + + return refreshTokenEntity; + } +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/security/UserDetailsImpl.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/security/UserDetailsImpl.java new file mode 100644 index 0000000..fe9fac5 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/security/UserDetailsImpl.java @@ -0,0 +1,46 @@ +package com.smartjam.smartjamapi.security; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +import com.smartjam.smartjamapi.entity.UserEntity; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +@AllArgsConstructor +@NullMarked +public class UserDetailsImpl implements UserDetails { + @Getter + private UUID id; + + private String username; + + @Getter + private String email; + + private String PasswordHash; + + public static UserDetailsImpl build(UserEntity user) { + return new UserDetailsImpl(user.getId(), user.getNickname(), user.getEmail(), user.getPasswordHash()); + } + + @Override + public Collection getAuthorities() { + return List.of(); // пока так, не знаю что сюда пихать + } + + @Override + public @Nullable String getPassword() { + return PasswordHash; + } + + @Override + public String getUsername() { + return email; + } +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/service/AuthService.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/service/AuthService.java new file mode 100644 index 0000000..f2bc04a --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/service/AuthService.java @@ -0,0 +1,147 @@ +package com.smartjam.smartjamapi.service; + +import java.time.Instant; +import java.util.NoSuchElementException; + +import com.smartjam.smartjamapi.dto.AuthResponse; +import com.smartjam.smartjamapi.dto.LoginRequest; +import com.smartjam.smartjamapi.dto.RefreshTokenRequest; +import com.smartjam.smartjamapi.dto.RegisterRequest; +import com.smartjam.smartjamapi.entity.RefreshTokenEntity; +import com.smartjam.smartjamapi.entity.UserEntity; +import com.smartjam.smartjamapi.enums.Role; +import com.smartjam.smartjamapi.enums.StatusRefreshToken; +import com.smartjam.smartjamapi.exception.TokenExpiredException; +import com.smartjam.smartjamapi.exception.TokenNotFoundException; +import com.smartjam.smartjamapi.mapper.UserMapper; +import com.smartjam.smartjamapi.repository.RefreshTokenRepository; +import com.smartjam.smartjamapi.repository.UserRepository; +import com.smartjam.smartjamapi.security.JwtService; +import com.smartjam.smartjamapi.security.RefreshTokenService; +import com.smartjam.smartjamapi.security.UserDetailsImpl; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@AllArgsConstructor +@Slf4j +public class AuthService { + + private final UserRepository repository; + private final RefreshTokenRepository refreshTokenRepository; + + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + private final RefreshTokenService refreshTokenService; + + private final UserMapper userMapper; + + @Transactional + public AuthResponse login(LoginRequest request) { + UserEntity userEntity = repository + .findByEmail(request.email()) + .orElseThrow(() -> new NoSuchElementException("Login not found, try register, please")); + if (!passwordEncoder.matches(request.password(), userEntity.getPasswordHash())) { + throw new IllegalStateException("Invalid password"); + } + + UserDetailsImpl userDetails = UserDetailsImpl.build(userEntity); + + String accessToken = jwtService.generateAccessToken(userDetails); + String refreshToken = refreshTokenService.generateRefreshToken(); + + RefreshTokenEntity refreshTokenEntity = refreshTokenService.create(userEntity, refreshToken); + + refreshTokenRepository.setStatusUsedRefreshToken( + userEntity, StatusRefreshToken.ACTIVE, StatusRefreshToken.USED); + + refreshTokenRepository.save(refreshTokenEntity); + + log.info("Login successful"); + + return new AuthResponse(refreshToken, accessToken); + } + + @Transactional + public AuthResponse register(RegisterRequest request) { + + if (repository.existsByEmail(request.email())) { + throw new IllegalStateException("The account exists, try login, please"); + } + + UserEntity userEntity = userMapper.toEntity(request); + + userEntity.setPasswordHash(passwordEncoder.encode(request.password())); + userEntity.setRole(Role.STUDENT); + + repository.save(userEntity); + + UserDetailsImpl userDetails = UserDetailsImpl.build(userEntity); + + String accessToken = jwtService.generateAccessToken(userDetails); + String refreshToken = refreshTokenService.generateRefreshToken(); + + RefreshTokenEntity refreshTokenEntity = refreshTokenService.create(userEntity, refreshToken); + + refreshTokenRepository.setStatusUsedRefreshToken( + userEntity, StatusRefreshToken.ACTIVE, StatusRefreshToken.USED); + + refreshTokenRepository.save(refreshTokenEntity); + + log.info("Register successful"); + + return new AuthResponse(refreshToken, accessToken); + } + + @Transactional + protected void revokeToken(String tokenHash) { + refreshTokenRepository.setStatusByRefreshToken(tokenHash, StatusRefreshToken.REVOKED); + + refreshTokenRepository.flush(); + } + + @Transactional + public AuthResponse getNewTokens(RefreshTokenRequest refreshTokenRequest) { + + String tokenHash = RefreshTokenService.hashRefreshToken(refreshTokenRequest.refreshToken()); + + log.info(tokenHash); + + RefreshTokenEntity refreshToken = refreshTokenRepository + .findByTokenHash(tokenHash) + .orElseThrow(() -> new TokenNotFoundException("Token not found, try login, please")); + + if (refreshToken.getStatus() == StatusRefreshToken.USED) { + revokeToken(tokenHash); + log.error(tokenHash); + + throw new SecurityException("Token reuse detected"); + } + + if (refreshToken.getExpiresAt().isBefore(Instant.now())) { + revokeToken(tokenHash); + log.error(tokenHash); + + throw new TokenExpiredException("Refresh token expired"); + } + + refreshTokenRepository.setStatusByRefreshToken(tokenHash, StatusRefreshToken.USED); + refreshTokenRepository.flush(); + + UserEntity userEntity = refreshToken.getUser(); + UserDetailsImpl userDetails = UserDetailsImpl.build(userEntity); + + String accessToken = jwtService.generateAccessToken(userDetails); + String newRefreshToken = refreshTokenService.generateRefreshToken(); + + RefreshTokenEntity refreshTokenEntity = refreshTokenService.create(userEntity, newRefreshToken); + refreshTokenRepository.save(refreshTokenEntity); + + log.info("New tokens successfully created"); + + return new AuthResponse(newRefreshToken, accessToken); + } +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/service/UploadService.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/service/UploadService.java new file mode 100644 index 0000000..44e1207 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/service/UploadService.java @@ -0,0 +1,85 @@ +package com.smartjam.smartjamapi.service; + +import java.net.URI; +import java.net.URL; +import java.time.Duration; + +import jakarta.annotation.PostConstruct; + +import com.smartjam.smartjamapi.dto.UploadUrlResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; + +@Service +@Slf4j +public class UploadService { + + private static final String BUCKET_NAME = "music"; + private static final String S3_ENDPOINT = "http://localhost:9000"; + private static final String ACCESS_KEY = "minioadmin"; + private static final String SECRET_KEY = "minioadmin"; + + private static S3Presigner presigner; + private static S3Client s3Client; + + @PostConstruct + public void init() { + if (s3Client == null) { + s3Client = S3Client.builder() + .endpointOverride(URI.create(S3_ENDPOINT)) + .region(Region.US_EAST_1) + .serviceConfiguration(b -> b.pathStyleAccessEnabled(true)) + .credentialsProvider( + StaticCredentialsProvider.create(AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY))) + .build(); + log.info("S3Client initialized"); + } + + if (presigner == null) { + presigner = S3Presigner.builder() + .endpointOverride(URI.create(S3_ENDPOINT)) + .region(Region.US_EAST_1) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(true) + .build()) + .credentialsProvider( + StaticCredentialsProvider.create(AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY))) + .build(); + log.info("S3Presigner initialized"); + } + + try { + log.info("Trying to connect to MinIO at {}", S3_ENDPOINT); + if (s3Client.listBuckets().buckets().stream() + .noneMatch(b -> b.name().equals(BUCKET_NAME))) { + s3Client.createBucket(b -> b.bucket(BUCKET_NAME)); + log.info("Bucket '{}' created successfully", BUCKET_NAME); + } else { + log.info("Bucket '{}' already exists", BUCKET_NAME); + } + } catch (Exception e) { + log.error("Failed to initialize bucket: {}", e.getMessage(), e); + } + } + + public UploadUrlResponse generateUploadUrl(String fileName) { + PutObjectRequest putObjectRequest = + PutObjectRequest.builder().bucket(BUCKET_NAME).key(fileName).build(); + + PresignedPutObjectRequest presignedGetObjectRequest = presigner.presignPutObject( + r -> r.putObjectRequest(putObjectRequest).signatureDuration(Duration.ofMinutes(10))); + + URL presignedUrl = presignedGetObjectRequest.url(); + log.info("Generated upload URL for file: {}", fileName); + + return new UploadUrlResponse(presignedUrl.toString()); + } +} diff --git a/backend/smartjam-api/src/main/resources/application.yaml b/backend/smartjam-api/src/main/resources/application.yaml index 4064ef2..0dbdb99 100644 Binary files a/backend/smartjam-api/src/main/resources/application.yaml and b/backend/smartjam-api/src/main/resources/application.yaml differ