diff --git a/.gitignore b/.gitignore index 667aaef..736bc8c 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ build/ ### VS Code ### .vscode/ + +### Secrets +secrets \ No newline at end of file diff --git a/pom.xml b/pom.xml index c93f7d5..53ac478 100644 --- a/pom.xml +++ b/pom.xml @@ -77,6 +77,33 @@ spring-security-test test + + + + + io.jsonwebtoken + jjwt-api + 0.13.0 + compile + + + + + io.jsonwebtoken + jjwt-impl + 0.13.0 + runtime + + + + + io.jsonwebtoken + jjwt-jackson + 0.13.0 + runtime + + + diff --git a/src/main/java/com/SkillsForge/expensetracker/ExpenseTrackerBackendJavaApplication.java b/src/main/java/com/SkillsForge/expensetracker/ExpenseTrackerBackendJavaApplication.java index 7f7ff62..7748646 100644 --- a/src/main/java/com/SkillsForge/expensetracker/ExpenseTrackerBackendJavaApplication.java +++ b/src/main/java/com/SkillsForge/expensetracker/ExpenseTrackerBackendJavaApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication +@EnableJpaAuditing public class ExpenseTrackerBackendJavaApplication { public static void main(String[] args) { diff --git a/src/main/java/com/SkillsForge/expensetracker/app/Config.java b/src/main/java/com/SkillsForge/expensetracker/app/Config.java index 1c94cb7..b42bacd 100644 --- a/src/main/java/com/SkillsForge/expensetracker/app/Config.java +++ b/src/main/java/com/SkillsForge/expensetracker/app/Config.java @@ -1,3 +1,67 @@ package com.SkillsForge.expensetracker.app; -public class Config {} +import com.SkillsForge.expensetracker.security.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +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.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity // Enables @PreAuthorize, @PostAuthorize annotations +@RequiredArgsConstructor +public class Config { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + // Disable CSRF (Cross-Site Request Forgery) protection + // We use stateless JWT, so CSRF is not needed + .csrf(AbstractHttpConfigurer::disable) + + // Configure endpoint authorization + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/v1/auth/signup", + "/api/v1/auth/login", + "/api/v1/auth/refresh-token" // if you have one + ).permitAll() + + // LOCKED DOORS (Everything else, including /auth/current-user) + .anyRequest().authenticated() + ) + // Configure session management + // STATELESS = no server-side sessions, use JWT only + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // Add our JWT filter before Spring Security's authentication filter + // This ensures JWT validation happens before standard authentication + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) + throws Exception { + return config.getAuthenticationManager(); + } +} diff --git a/src/main/java/com/SkillsForge/expensetracker/app/enums/Permissions.java b/src/main/java/com/SkillsForge/expensetracker/app/enums/Permissions.java new file mode 100644 index 0000000..90e8ae8 --- /dev/null +++ b/src/main/java/com/SkillsForge/expensetracker/app/enums/Permissions.java @@ -0,0 +1,10 @@ +package com.SkillsForge.expensetracker.app.enums; + +public enum Permissions { + TRANSACTION_READ, + TRANSACTION_WRITE, + TRANSACTION_DELETE, + USER_READ, + USER_WRITE, + ADMIN_ACCESS +} diff --git a/src/main/java/com/SkillsForge/expensetracker/app/enums/Role.java b/src/main/java/com/SkillsForge/expensetracker/app/enums/Role.java new file mode 100644 index 0000000..cac7883 --- /dev/null +++ b/src/main/java/com/SkillsForge/expensetracker/app/enums/Role.java @@ -0,0 +1,20 @@ +package com.SkillsForge.expensetracker.app.enums; + +import java.util.Set; + +public enum Role { + USER, + ADMIN; + + public Set getPermissions() { + return switch (this) { + case ADMIN -> Set.of(Permissions.values()); // Admin has all permissions + case USER -> Set.of( + Permissions.TRANSACTION_READ, + Permissions.TRANSACTION_WRITE, + Permissions.TRANSACTION_DELETE, + Permissions.USER_READ, + Permissions.USER_WRITE); + }; + } +} diff --git a/src/main/java/com/SkillsForge/expensetracker/controller/AuthController.java b/src/main/java/com/SkillsForge/expensetracker/controller/AuthController.java new file mode 100644 index 0000000..41466d0 --- /dev/null +++ b/src/main/java/com/SkillsForge/expensetracker/controller/AuthController.java @@ -0,0 +1,45 @@ +package com.SkillsForge.expensetracker.controller; + +import com.SkillsForge.expensetracker.dto.AuthResponse; +import com.SkillsForge.expensetracker.dto.LoginRequest; +import com.SkillsForge.expensetracker.dto.SignupRequest; +import com.SkillsForge.expensetracker.dto.UserDto; +import com.SkillsForge.expensetracker.service.UserService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +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; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class AuthController { + + private final UserService userService; + + @PostMapping("/signup") + public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { + AuthResponse response = userService.signup(request); + return new ResponseEntity<>(response, HttpStatus.CREATED); + } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + AuthResponse response = userService.login(request); + return ResponseEntity.ok(response); + } + + + @GetMapping("/me") + @PreAuthorize("hasAuthority('USER_READ')") + public ResponseEntity getCurrentUser() { + UserDto user = userService.getCurrentUser(); + return ResponseEntity.ok(user); + } +} diff --git a/src/main/java/com/SkillsForge/expensetracker/controller/TransactionController.java b/src/main/java/com/SkillsForge/expensetracker/controller/TransactionController.java index 09358b2..189dc22 100644 --- a/src/main/java/com/SkillsForge/expensetracker/controller/TransactionController.java +++ b/src/main/java/com/SkillsForge/expensetracker/controller/TransactionController.java @@ -9,6 +9,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -20,23 +21,27 @@ public class TransactionController { @PostMapping @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("hasAuthority('TRANSACTION_WRITE')") public TransactionDto createTransaction( @RequestBody @Validated CreateTransactionRequest request) { return transactionService.createTransaction(request); } @GetMapping("/{id}") + @PreAuthorize("hasAuthority('TRANSACTION_READ')") public TransactionDto getTransactionById(@PathVariable Long id) { return transactionService.getTransactionById(id); } @PutMapping("/{id}") + @PreAuthorize("hasAuthority('TRANSACTION_WRITE')") public TransactionDto updateTransaction( @PathVariable Long id, @RequestBody TransactionUpdateRequest request) { return transactionService.updateTransaction(id, request); } @GetMapping + @PreAuthorize("hasAuthority('TRANSACTION_READ')") public Page getAllTransactions( @ModelAttribute TransactionFilter filter, Pageable pageable) { return transactionService.getAllTransactions(filter, pageable); diff --git a/src/main/java/com/SkillsForge/expensetracker/dto/AuthResponse.java b/src/main/java/com/SkillsForge/expensetracker/dto/AuthResponse.java new file mode 100644 index 0000000..e7d2072 --- /dev/null +++ b/src/main/java/com/SkillsForge/expensetracker/dto/AuthResponse.java @@ -0,0 +1,21 @@ +package com.SkillsForge.expensetracker.dto; + +import com.SkillsForge.expensetracker.app.enums.Role; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class AuthResponse { + // no need for validation, this is output + private String token; // the JWT token string + private String type; // token type, always "Bearer" + private Long id; + private String username; + private String email; + private Role role; // USER or ADMIN +} diff --git a/src/main/java/com/SkillsForge/expensetracker/dto/LoginRequest.java b/src/main/java/com/SkillsForge/expensetracker/dto/LoginRequest.java new file mode 100644 index 0000000..5d26f0e --- /dev/null +++ b/src/main/java/com/SkillsForge/expensetracker/dto/LoginRequest.java @@ -0,0 +1,19 @@ +package com.SkillsForge.expensetracker.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class LoginRequest { + @NotBlank(message = "Username cannot be blank") + private String username; + + @NotBlank(message = "Password cannot be blank") + private String password; +} diff --git a/src/main/java/com/SkillsForge/expensetracker/dto/SignupRequest.java b/src/main/java/com/SkillsForge/expensetracker/dto/SignupRequest.java new file mode 100644 index 0000000..d68976d --- /dev/null +++ b/src/main/java/com/SkillsForge/expensetracker/dto/SignupRequest.java @@ -0,0 +1,27 @@ +package com.SkillsForge.expensetracker.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class SignupRequest { + @NotBlank(message = "Email is required") + @Email + private String email; + + @NotBlank(message = "Username cannot be blank") + @Size(min = 3, max = 20, message = "Username must be between 3 and 20 characters") + private String username; + + @NotBlank(message = "Password is required") + @Size(min = 6, max = 50, message = "Password must be between 6 and 50 characters") + private String password; +} diff --git a/src/main/java/com/SkillsForge/expensetracker/dto/UserDto.java b/src/main/java/com/SkillsForge/expensetracker/dto/UserDto.java new file mode 100644 index 0000000..4893732 --- /dev/null +++ b/src/main/java/com/SkillsForge/expensetracker/dto/UserDto.java @@ -0,0 +1,25 @@ +package com.SkillsForge.expensetracker.dto; + +import com.SkillsForge.expensetracker.app.enums.Role; +import com.SkillsForge.expensetracker.persistence.entity.User; +import java.time.LocalDateTime; + +public record UserDto( + Long id, + String username, + String email, + Role role, + boolean enabled, + LocalDateTime createdAt, + LocalDateTime updatedAt) { + public static UserDto from(User user) { + return new UserDto( + user.getId(), + user.getUsername(), + user.getEmail(), + user.getRole(), + user.isEnabled(), + user.getCreatedAt(), + user.getUpdatedAt()); + } +} diff --git a/src/main/java/com/SkillsForge/expensetracker/exception/EmailAlreadyExistsException.java b/src/main/java/com/SkillsForge/expensetracker/exception/EmailAlreadyExistsException.java new file mode 100644 index 0000000..b34e47f --- /dev/null +++ b/src/main/java/com/SkillsForge/expensetracker/exception/EmailAlreadyExistsException.java @@ -0,0 +1,7 @@ +package com.SkillsForge.expensetracker.exception; + +public class EmailAlreadyExistsException extends RuntimeException { + public EmailAlreadyExistsException(String message) { + super(message); + } +} diff --git a/src/main/java/com/SkillsForge/expensetracker/exception/GlobalExceptionHandler.java b/src/main/java/com/SkillsForge/expensetracker/exception/GlobalExceptionHandler.java index aac3fc5..6f2930c 100644 --- a/src/main/java/com/SkillsForge/expensetracker/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/SkillsForge/expensetracker/exception/GlobalExceptionHandler.java @@ -55,4 +55,94 @@ public ResponseEntity handleGenericException( return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); } + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity handleResourceNotFound( + ResourceNotFoundException ex, HttpServletRequest request) { + + log.error("Resource not found: {}", ex.getMessage()); + + ErrorResponse errorResponse = + ErrorResponse.builder() + .timestamp(LocalDateTime.now()) + .status(HttpStatus.NOT_FOUND.value()) + .error(HttpStatus.NOT_FOUND.getReasonPhrase()) + .message(ex.getMessage()) + .path(request.getRequestURI()) + .build(); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); + } + + @ExceptionHandler(UsernameAlreadyExistsException.class) + public ResponseEntity handleUsernameAlreadyExists( + UsernameAlreadyExistsException ex, HttpServletRequest request) { + + log.error("Username already exists: {}", ex.getMessage()); + + ErrorResponse errorResponse = + ErrorResponse.builder() + .timestamp(LocalDateTime.now()) + .status(HttpStatus.CONFLICT.value()) + .error(HttpStatus.CONFLICT.getReasonPhrase()) + .message(ex.getMessage()) + .path(request.getRequestURI()) + .build(); + + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + @ExceptionHandler(EmailAlreadyExistsException.class) + public ResponseEntity handleEmailAlreadyExists( + EmailAlreadyExistsException ex, HttpServletRequest request) { + + log.error("Email already exists: {}", ex.getMessage()); + + ErrorResponse errorResponse = + ErrorResponse.builder() + .timestamp(LocalDateTime.now()) + .status(HttpStatus.CONFLICT.value()) + .error(HttpStatus.CONFLICT.getReasonPhrase()) + .message(ex.getMessage()) + .path(request.getRequestURI()) + .build(); + + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + @ExceptionHandler(InvalidCredentialsException.class) + public ResponseEntity handleInvalidCredentials( + InvalidCredentialsException ex, HttpServletRequest request) { + + log.error("Invalid credentials: {}", ex.getMessage()); + + ErrorResponse errorResponse = + ErrorResponse.builder() + .timestamp(LocalDateTime.now()) + .status(HttpStatus.UNAUTHORIZED.value()) + .error(HttpStatus.UNAUTHORIZED.getReasonPhrase()) + .message(ex.getMessage()) + .path(request.getRequestURI()) + .build(); + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse); + } + + @ExceptionHandler(org.springframework.security.access.AccessDeniedException.class) + public ResponseEntity handleAccessDenied( + org.springframework.security.access.AccessDeniedException ex, HttpServletRequest request) { + + log.error("Access denied: {}", ex.getMessage()); + + ErrorResponse errorResponse = + ErrorResponse.builder() + .timestamp(LocalDateTime.now()) + .status(HttpStatus.FORBIDDEN.value()) + .error(HttpStatus.FORBIDDEN.getReasonPhrase()) + .message("You don't have permission to access this resource") + .path(request.getRequestURI()) + .build(); + + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse); + } } diff --git a/src/main/java/com/SkillsForge/expensetracker/exception/InvalidCredentialsException.java b/src/main/java/com/SkillsForge/expensetracker/exception/InvalidCredentialsException.java new file mode 100644 index 0000000..ef4d69e --- /dev/null +++ b/src/main/java/com/SkillsForge/expensetracker/exception/InvalidCredentialsException.java @@ -0,0 +1,7 @@ +package com.SkillsForge.expensetracker.exception; + +public class InvalidCredentialsException extends RuntimeException { + public InvalidCredentialsException(String message) { + super(message); + } +} diff --git a/src/main/java/com/SkillsForge/expensetracker/exception/UsernameAlreadyExistsException.java b/src/main/java/com/SkillsForge/expensetracker/exception/UsernameAlreadyExistsException.java new file mode 100644 index 0000000..a77f2cb --- /dev/null +++ b/src/main/java/com/SkillsForge/expensetracker/exception/UsernameAlreadyExistsException.java @@ -0,0 +1,7 @@ +package com.SkillsForge.expensetracker.exception; + +public class UsernameAlreadyExistsException extends RuntimeException { + public UsernameAlreadyExistsException(String message) { + super(message); + } +} diff --git a/src/main/java/com/SkillsForge/expensetracker/persistence/entity/BaseEntity.java b/src/main/java/com/SkillsForge/expensetracker/persistence/entity/BaseEntity.java index cb59686..0d2a22c 100644 --- a/src/main/java/com/SkillsForge/expensetracker/persistence/entity/BaseEntity.java +++ b/src/main/java/com/SkillsForge/expensetracker/persistence/entity/BaseEntity.java @@ -5,6 +5,9 @@ import java.util.Objects; import lombok.*; import lombok.experimental.SuperBuilder; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; @MappedSuperclass @Getter @@ -13,14 +16,17 @@ @RequiredArgsConstructor @SuperBuilder @AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) public class BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) protected Long id; + @CreatedDate @Column(nullable = false) LocalDateTime createdAt; + @LastModifiedDate @Column(nullable = false) LocalDateTime updatedAt; diff --git a/src/main/java/com/SkillsForge/expensetracker/persistence/entity/Transaction.java b/src/main/java/com/SkillsForge/expensetracker/persistence/entity/Transaction.java index f11327a..95ac14e 100644 --- a/src/main/java/com/SkillsForge/expensetracker/persistence/entity/Transaction.java +++ b/src/main/java/com/SkillsForge/expensetracker/persistence/entity/Transaction.java @@ -38,6 +38,11 @@ public class Transaction extends BaseEntity { @Column(nullable = false) private Long amount; // We save amount in kobo value so 1.50 naira will be saved as 150 + // user relationship + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + // constructor to accept dto public Transaction(CreateTransactionRequest request) { this.description = request.getDescription(); diff --git a/src/main/java/com/SkillsForge/expensetracker/persistence/entity/User.java b/src/main/java/com/SkillsForge/expensetracker/persistence/entity/User.java new file mode 100644 index 0000000..25b794d --- /dev/null +++ b/src/main/java/com/SkillsForge/expensetracker/persistence/entity/User.java @@ -0,0 +1,49 @@ +package com.SkillsForge.expensetracker.persistence.entity; + +import com.SkillsForge.expensetracker.app.enums.Role; +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.Accessors; +import lombok.experimental.SuperBuilder; + +@Accessors(chain = true) +@Entity +@Table(name = "users") +@Getter +@Setter +@ToString +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public class User extends BaseEntity { + + @Column(unique = true, nullable = false) + private String username; + + @Column(unique = true, nullable = false) + private String email; + + @Column(nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Builder.Default + private Role role = Role.USER; + + @Column(nullable = false) + @Builder.Default + private boolean enabled = true; + + @Column(nullable = false) + @Builder.Default + private boolean accountNonExpired = true; + + @Column(nullable = false) + @Builder.Default + private boolean accountNonLocked = true; + + @Column(nullable = false) + @Builder.Default + private boolean credentialsNonExpired = true; +} diff --git a/src/main/java/com/SkillsForge/expensetracker/persistence/repository/TransactionRepository.java b/src/main/java/com/SkillsForge/expensetracker/persistence/repository/TransactionRepository.java index 162c69d..56995a9 100644 --- a/src/main/java/com/SkillsForge/expensetracker/persistence/repository/TransactionRepository.java +++ b/src/main/java/com/SkillsForge/expensetracker/persistence/repository/TransactionRepository.java @@ -1,8 +1,20 @@ package com.SkillsForge.expensetracker.persistence.repository; import com.SkillsForge.expensetracker.persistence.entity.Transaction; +import com.SkillsForge.expensetracker.persistence.entity.User; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; public interface TransactionRepository - extends JpaRepository, JpaSpecificationExecutor {} + extends JpaRepository, JpaSpecificationExecutor { + + Page findAllByUser(User user, Pageable pageable); + + Page findAll(Specification spec, Pageable pageable); + + Optional findByIdAndUser(Long id, User user); +} diff --git a/src/main/java/com/SkillsForge/expensetracker/persistence/repository/UserRepository.java b/src/main/java/com/SkillsForge/expensetracker/persistence/repository/UserRepository.java new file mode 100644 index 0000000..74da654 --- /dev/null +++ b/src/main/java/com/SkillsForge/expensetracker/persistence/repository/UserRepository.java @@ -0,0 +1,15 @@ +package com.SkillsForge.expensetracker.persistence.repository; + +import com.SkillsForge.expensetracker.persistence.entity.User; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + + Optional findByEmail(String email); + + boolean existsByUsername(String username); + + boolean existsByEmail(String email); +} diff --git a/src/main/java/com/SkillsForge/expensetracker/security/JwtAuthenticationFilter.java b/src/main/java/com/SkillsForge/expensetracker/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..275a81b --- /dev/null +++ b/src/main/java/com/SkillsForge/expensetracker/security/JwtAuthenticationFilter.java @@ -0,0 +1,75 @@ +package com.SkillsForge.expensetracker.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final UserDetailsService userDetailsService; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) + throws ServletException, IOException { + + // Extract the Authorization header from the request + final String authorizationHeader = request.getHeader("Authorization"); + + String username = null; + String jwt = null; + + // Check if header exists and starts with "Bearer " + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + jwt = authorizationHeader.substring(7); + try { + username = jwtUtil.extractUsername(jwt); + } catch (Exception e) { + // Print the full error to see if it's a Key or Format issue + logger.error("CRITICAL JWT FAILURE: " + e.getMessage()); + e.printStackTrace(); + } + } + + // If we have a username and no authentication exists in SecurityContext + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + + // Load user details from database + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + // Validate the token + if (jwtUtil.isTokenValid(jwt, userDetails)) { + // Token is valid - create authentication object + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + + // Set additional details (IP address, session ID, etc.) + authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + // Set authentication in SecurityContext + // Now Spring Security knows this request is authenticated + SecurityContextHolder.getContext().setAuthentication(authenticationToken); + } + } + + // Continue the filter chain (pass request to next filter or controller) + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/SkillsForge/expensetracker/security/JwtUtil.java b/src/main/java/com/SkillsForge/expensetracker/security/JwtUtil.java new file mode 100644 index 0000000..b7a6259 --- /dev/null +++ b/src/main/java/com/SkillsForge/expensetracker/security/JwtUtil.java @@ -0,0 +1,79 @@ +package com.SkillsForge.expensetracker.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +@Component +public class JwtUtil { + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.expiration}") + private long jwtExpiration; + + 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); + } + + public String generateToken(UserDetails userDetails) { + return generateToken(new HashMap<>(), userDetails); + } + + // Renamed to conform to modern standards (using UserDetails) + public String generateToken(Map extraClaims, UserDetails userDetails) { + return buildToken(extraClaims, userDetails, jwtExpiration); + } + + private String buildToken( + Map extraClaims, UserDetails userDetails, long expiration) { + return Jwts.builder() + .claims(extraClaims) // New method: 'claims' instead of 'setClaims' + .subject(userDetails.getUsername()) // New method: 'subject' instead of 'setSubject' + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSignInKey(), Jwts.SIG.HS256) // MODERN: Use Jwts.SIG + .compact(); + } + + public boolean isTokenValid(String token, UserDetails userDetails) { + final String username = extractUsername(token); + return (username.equals(userDetails.getUsername())) && !isTokenExpired(token); + } + + private boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + private Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + private Claims extractAllClaims(String token) { + return Jwts.parser() + .verifyWith(getSignInKey()) // MODERN: 'verifyWith' checks the signature + .build() + .parseSignedClaims(token) // MODERN: 'parseSignedClaims' instead of 'parseClaimsJws' + .getPayload(); // MODERN: 'getPayload' instead of 'getBody' + } + + private SecretKey getSignInKey() { + byte[] keyBytes = Decoders.BASE64.decode(secret); + return Keys.hmacShaKeyFor(keyBytes); + } +} diff --git a/src/main/java/com/SkillsForge/expensetracker/security/UserDetailsServiceImpl.java b/src/main/java/com/SkillsForge/expensetracker/security/UserDetailsServiceImpl.java new file mode 100644 index 0000000..84435d2 --- /dev/null +++ b/src/main/java/com/SkillsForge/expensetracker/security/UserDetailsServiceImpl.java @@ -0,0 +1,60 @@ +package com.SkillsForge.expensetracker.security; + +import com.SkillsForge.expensetracker.persistence.entity.User; +import com.SkillsForge.expensetracker.persistence.repository.UserRepository; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserDetailsServiceImpl implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + // Find user in database + User user = + userRepository + .findByUsername(username) + .orElseThrow( + () -> new UsernameNotFoundException("User not found with username: " + username)); + + // Convert our User entity to Spring Security's UserDetails + return buildUserDetails(user); + } + + private UserDetails buildUserDetails(User user) { + return org.springframework.security.core.userdetails.User.builder() + .username(user.getUsername()) + .password(user.getPassword()) // BCrypt hashed password + .authorities(getAuthorities(user)) // Convert role to authorities + .accountExpired(!user.isAccountNonExpired()) + .accountLocked(!user.isAccountNonLocked()) + .credentialsExpired(!user.isCredentialsNonExpired()) + .disabled(!user.isEnabled()) + .build(); + } + + private Collection getAuthorities(User user) { + Set authorities = new HashSet<>(); + + // Add role as authority (Spring Security expects "ROLE_" prefix) + authorities.add(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())); + + // Add permissions from the role + user.getRole() + .getPermissions() + .forEach(permission -> authorities.add(new SimpleGrantedAuthority(permission.name()))); + + return authorities; + } +} diff --git a/src/main/java/com/SkillsForge/expensetracker/service/TransactionServiceImpl.java b/src/main/java/com/SkillsForge/expensetracker/service/TransactionServiceImpl.java index 25cf00e..0cc9e5a 100644 --- a/src/main/java/com/SkillsForge/expensetracker/service/TransactionServiceImpl.java +++ b/src/main/java/com/SkillsForge/expensetracker/service/TransactionServiceImpl.java @@ -7,6 +7,8 @@ import com.SkillsForge.expensetracker.exception.ResourceNotFoundException; import com.SkillsForge.expensetracker.persistence.entity.Transaction; import com.SkillsForge.expensetracker.persistence.repository.TransactionRepository; +import com.SkillsForge.expensetracker.persistence.entity.User; +import com.SkillsForge.expensetracker.persistence.repository.UserRepository; import jakarta.persistence.criteria.Predicate; import java.time.LocalDateTime; import java.util.ArrayList; @@ -16,6 +18,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,48 +28,46 @@ public class TransactionServiceImpl implements TransactionService { private final TransactionRepository transactionRepository; + private final UserRepository userRepository; - // ================= CREATE ================= @Override @Transactional public TransactionDto createTransaction(CreateTransactionRequest request) { - LocalDateTime now = LocalDateTime.now(); + User user = getCurrentUser(); Transaction transaction = new Transaction(request); - transaction.setCreatedAt(now); - transaction.setUpdatedAt(now); + transaction.setUser(user); + transaction.setCreatedAt(LocalDateTime.now()); + transaction.setUpdatedAt(LocalDateTime.now()); Transaction saved = transactionRepository.save(transaction); - log.info("Transaction created successfully with ID: {}", saved.getId()); + log.info("Transaction created with ID: {}", saved.getId()); return TransactionDto.fromEntity(saved); } - // ================= GET BY ID ================= @Override @Transactional(readOnly = true) public TransactionDto getTransactionById(Long id) { - log.info("Fetching transaction with ID: {}", id); + User user = getCurrentUser(); - Transaction transaction = - transactionRepository - .findById(id) - .orElseThrow( - () -> new ResourceNotFoundException("Transaction not found with ID: " + id)); + Transaction transaction = transactionRepository + .findByIdAndUser(id, user) + .orElseThrow( + () -> new ResourceNotFoundException("Transaction not found with ID: " + id)); return TransactionDto.fromEntity(transaction); } - // ================= UPDATE ================= @Override @Transactional public TransactionDto updateTransaction(Long id, TransactionUpdateRequest request) { + User user = getCurrentUser(); - Transaction existing = - transactionRepository - .findById(id) - .orElseThrow( - () -> new ResourceNotFoundException("Transaction not found with ID: " + id)); + Transaction existing = transactionRepository + .findByIdAndUser(id, user) + .orElseThrow( + () -> new ResourceNotFoundException("Transaction not found with ID: " + id)); existing.setDescription(request.getDescription()); existing.setCategory(request.getCategory()); @@ -76,31 +77,40 @@ public TransactionDto updateTransaction(Long id, TransactionUpdateRequest reques existing.setUpdatedAt(LocalDateTime.now()); Transaction saved = transactionRepository.save(existing); - log.info("Transaction updated successfully with ID: {}", saved.getId()); + log.info("Transaction updated with ID: {}", saved.getId()); return TransactionDto.fromEntity(saved); } - // ================= GET ALL (FILTERED) ================= @Override @Transactional(readOnly = true) public Page getAllTransactions(TransactionFilter filter, Pageable pageable) { + User user = getCurrentUser(); - Specification spec = - (root, query, cb) -> { - List predicates = new ArrayList<>(); + Specification spec = (root, query, cb) -> { + List predicates = new ArrayList<>(); - if (filter.getCategory() != null) { - predicates.add(cb.equal(root.get("category"), filter.getCategory())); - } + // Force filter by current user + predicates.add(cb.equal(root.get("user"), user)); - if (filter.getType() != null) { - predicates.add(cb.equal(root.get("type"), filter.getType())); - } + if (filter.getCategory() != null) { + predicates.add(cb.equal(root.get("category"), filter.getCategory())); + } - return cb.and(predicates.toArray(new Predicate[0])); - }; + if (filter.getType() != null) { + predicates.add(cb.equal(root.get("type"), filter.getType())); + } + + return cb.and(predicates.toArray(new Predicate[0])); + }; return transactionRepository.findAll(spec, pageable).map(TransactionDto::fromEntity); } + + private User getCurrentUser() { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + return userRepository + .findByUsername(username) + .orElseThrow(() -> new ResourceNotFoundException("User not found")); + } } diff --git a/src/main/java/com/SkillsForge/expensetracker/service/UserService.java b/src/main/java/com/SkillsForge/expensetracker/service/UserService.java new file mode 100644 index 0000000..db3848e --- /dev/null +++ b/src/main/java/com/SkillsForge/expensetracker/service/UserService.java @@ -0,0 +1,15 @@ +package com.SkillsForge.expensetracker.service; + +import com.SkillsForge.expensetracker.dto.AuthResponse; +import com.SkillsForge.expensetracker.dto.LoginRequest; +import com.SkillsForge.expensetracker.dto.SignupRequest; +import com.SkillsForge.expensetracker.dto.UserDto; + +public interface UserService { + + AuthResponse signup(SignupRequest request); + + AuthResponse login(LoginRequest request); + + UserDto getCurrentUser(); +} diff --git a/src/main/java/com/SkillsForge/expensetracker/service/UserServiceImpl.java b/src/main/java/com/SkillsForge/expensetracker/service/UserServiceImpl.java new file mode 100644 index 0000000..b3f167c --- /dev/null +++ b/src/main/java/com/SkillsForge/expensetracker/service/UserServiceImpl.java @@ -0,0 +1,153 @@ +package com.SkillsForge.expensetracker.service; + +import com.SkillsForge.expensetracker.app.enums.Role; +import com.SkillsForge.expensetracker.dto.AuthResponse; +import com.SkillsForge.expensetracker.dto.LoginRequest; +import com.SkillsForge.expensetracker.dto.SignupRequest; +import com.SkillsForge.expensetracker.dto.UserDto; +import com.SkillsForge.expensetracker.exception.EmailAlreadyExistsException; +import com.SkillsForge.expensetracker.exception.InvalidCredentialsException; +import com.SkillsForge.expensetracker.exception.UsernameAlreadyExistsException; +import com.SkillsForge.expensetracker.persistence.entity.User; +import com.SkillsForge.expensetracker.persistence.repository.UserRepository; +import com.SkillsForge.expensetracker.security.JwtUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final AuthenticationManager authenticationManager; + private final JwtUtil jwtUtil; + + @Override + @Transactional + public AuthResponse signup(SignupRequest request) { + log.info("Attempting to register new user: {}", request.getUsername()); + + // Check if username already exists + if (userRepository.existsByUsername(request.getUsername())) { + log.warn("Signup failed: username already exists - {}", request.getUsername()); + throw new UsernameAlreadyExistsException("Username already taken: " + request.getUsername()); + } + + // Check if email already exists + if (userRepository.existsByEmail(request.getEmail())) { + log.warn("Signup failed: email already exists - {}", request.getEmail()); + throw new EmailAlreadyExistsException("Email already registered: " + request.getEmail()); + } + + // Create new user with hashed password + User user = + User.builder() + .username(request.getUsername()) + .email(request.getEmail()) + .password(passwordEncoder.encode(request.getPassword())) // Hash password with BCrypt + .role(Role.USER) // Default role + .enabled(true) + .accountNonExpired(true) + .accountNonLocked(true) + .credentialsNonExpired(true) + .build(); + + // Save user to database + User savedUser = userRepository.save(user); + log.info("Successfully registered new user with ID: {}", savedUser.getId()); + + // Create UserDetails for token generation + org.springframework.security.core.userdetails.UserDetails userDetails = + org.springframework.security.core.userdetails.User.builder() + .username(savedUser.getUsername()) + .password(savedUser.getPassword()) + .authorities("ROLE_" + savedUser.getRole().name()) + .build(); + + // Generate JWT token for automatic login + String token = jwtUtil.generateToken(userDetails); + + // Build and return authentication response + return AuthResponse.builder() + .token(token) + .type("Bearer") + .id(savedUser.getId()) + .username(savedUser.getUsername()) + .email(savedUser.getEmail()) + .role(savedUser.getRole()) + .build(); + } + + @Override + public AuthResponse login(LoginRequest request) { + log.info("Login attempt for user: {}", request.getUsername()); + + try { + // Authenticate user credentials + // Spring Security will compare plain password with BCrypt hash + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())); + } catch (AuthenticationException e) { + log.warn("Login failed for user: {} - Invalid credentials", request.getUsername()); + throw new InvalidCredentialsException("Invalid username or password"); + } + + // Load user from database (authentication successful) + User user = + userRepository + .findByUsername(request.getUsername()) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + log.info("User {} logged in successfully", user.getUsername()); + + // Create UserDetails for token generation + org.springframework.security.core.userdetails.UserDetails userDetails = + org.springframework.security.core.userdetails.User.builder() + .username(user.getUsername()) + .password(user.getPassword()) + .authorities("ROLE_" + user.getRole().name()) + .build(); + + // Generate JWT token + String token = jwtUtil.generateToken(userDetails); + + // Build and return authentication response + return AuthResponse.builder() + .token(token) + .type("Bearer") + .id(user.getId()) + .username(user.getUsername()) + .email(user.getEmail()) + .role(user.getRole()) + .build(); + } + + @Override + public UserDto getCurrentUser() { + // Get authentication from SecurityContext (set by JWT filter) + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String username = authentication.getName(); + + log.debug("Fetching current user: {}", username); + + // Load user from database + User user = + userRepository + .findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); + + // Convert to DTO (excludes password) + return UserDto.from(user); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e454b0f..f26d5aa 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -21,6 +21,9 @@ spring: # default-schema: public # drop-first: false +jwt: + secret: ${JWT_SECRET} + expiration: ${JWT_EXPIRATION:86400000} server: port: ${SERVER_PORT:8080} \ No newline at end of file diff --git a/src/main/resources/db/changelog/002_user_migration.xml b/src/main/resources/db/changelog/002_user_migration.xml new file mode 100644 index 0000000..bb3b2e9 --- /dev/null +++ b/src/main/resources/db/changelog/002_user_migration.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/db/changelog/003_add_user_to_transaction.xml b/src/main/resources/db/changelog/003_add_user_to_transaction.xml new file mode 100644 index 0000000..1c817c9 --- /dev/null +++ b/src/main/resources/db/changelog/003_add_user_to_transaction.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM users WHERE username = 'system_admin' + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM users WHERE username = 'system_admin' + + + + + UPDATE transactions + SET user_id = (SELECT id FROM users WHERE username = 'system_admin') + WHERE user_id IS NULL + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/db/changelog/changelog-master.xml b/src/main/resources/db/changelog/changelog-master.xml index 69815ec..015b05a 100644 --- a/src/main/resources/db/changelog/changelog-master.xml +++ b/src/main/resources/db/changelog/changelog-master.xml @@ -5,4 +5,6 @@ xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> + + \ No newline at end of file