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 extends GrantedAuthority> 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