Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@ jobs:
distribution: temurin
cache: maven

- name: Run tests
run: mvn clean test --batch-mode --no-transfer-progress
- name: Build and Test
run: mvn clean verify --batch-mode --no-transfer-progress
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@
<artifactId>zipkin-reporter-brave</artifactId>
</dependency>

<!-- AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

<!-- Testcontainers -->
<dependency>
<groupId>org.springframework.boot</groupId>
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/com/cn/hotelDemo/annotation/AuditLogged.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.cn.hotelDemo.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuditLogged {
String action();
String resourceType();
String resourceIdSpel() default "''";
}
76 changes: 76 additions & 0 deletions src/main/java/com/cn/hotelDemo/aspect/AuditAspect.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.cn.hotelDemo.aspect;

import java.lang.reflect.Method;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

import com.cn.hotelDemo.annotation.AuditLogged;
import com.cn.hotelDemo.service.AuditService;

@Aspect
@Component
public class AuditAspect {

private final AuditService auditService;
private final ExpressionParser parser = new SpelExpressionParser();

public AuditAspect(AuditService auditService) {
this.auditService = auditService;
}

@AfterReturning(pointcut = "@annotation(auditLogged)", returning = "result")
public void logAudit(JoinPoint joinPoint, AuditLogged auditLogged, Object result) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = (authentication != null && authentication.getName() != null) ? authentication.getName() : "anonymous";

String resourceId = resolveSpelExpression(joinPoint, result, auditLogged.resourceIdSpel(), "N/A");
String action = auditLogged.action().startsWith("#") ? resolveSpelExpression(joinPoint, result, auditLogged.action(), "UNKNOWN") : auditLogged.action();

auditService.record(
action,
username,
auditLogged.resourceType(),
resourceId,
"SUCCESS",
"Action performed successfully"
);
}

private String resolveSpelExpression(JoinPoint joinPoint, Object result, String spelExpression, String defaultValue) {
if (spelExpression == null || spelExpression.isEmpty() || spelExpression.equals("''")) {
return defaultValue;
}

EvaluationContext context = new StandardEvaluationContext();

// Add method arguments to context
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String[] parameterNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
if (parameterNames != null) {
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
}

// Add method return value to context
context.setVariable("result", result);

try {
Object evaluatedId = parser.parseExpression(spelExpression).getValue(context);
return evaluatedId != null ? String.valueOf(evaluatedId) : "N/A";
} catch (Exception e) {
return "UNKNOWN";
}
}
}
19 changes: 4 additions & 15 deletions src/main/java/com/cn/hotelDemo/controller/BookingController.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import com.cn.hotelDemo.dto.BookingResponse;
import com.cn.hotelDemo.model.Booking;
import com.cn.hotelDemo.model.BookingStatus;
import com.cn.hotelDemo.service.AuditService;
import com.cn.hotelDemo.annotation.AuditLogged;
import com.cn.hotelDemo.service.BookingService;

import io.swagger.v3.oas.annotations.Operation;
Expand All @@ -33,21 +33,17 @@
public class BookingController {

private final BookingService bookingService;
private final AuditService auditService;

public BookingController(BookingService bookingService, AuditService auditService) {
public BookingController(BookingService bookingService) {
this.bookingService = bookingService;
this.auditService = auditService;
}

@PostMapping("/create")
@PreAuthorize("hasRole('ADMIN') or hasAuthority('admin') or hasRole('NORMAL') or hasAuthority('normal')")
@Operation(summary = "Create a new booking")
@AuditLogged(action = "#result != null ? 'BOOKING_' + #result.body.status.name() : 'UNKNOWN'", resourceType = "BOOKING", resourceIdSpel = "#result.body.bookingId")
public ResponseEntity<BookingResponse> createBooking(@Valid @RequestBody BookingRequest bookingRequest,
Authentication authentication) {
BookingResponse response = bookingService.createBooking(bookingRequest);
auditService.record("BOOKING_" + response.getStatus().name(), authentication.getName(), "BOOKING",
String.valueOf(response.getBookingId()), response.getStatus().name(), response.getMessage());
return new ResponseEntity<>(response,
response.getStatus() == BookingStatus.CONFIRMED ? HttpStatus.CREATED : HttpStatus.CONFLICT);
}
Expand All @@ -57,9 +53,6 @@ public ResponseEntity<BookingResponse> createBooking(@Valid @RequestBody Booking
@Operation(summary = "Get a booking by its ID")
public ResponseEntity<Booking> getBookingById(@PathVariable Long id) {
Booking booking = bookingService.getBookingById(id);
if (booking == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(booking);
}

Expand Down Expand Up @@ -87,11 +80,9 @@ public ResponseEntity<List<Booking>> getBookingsByHotel(@PathVariable Long hotel
@PostMapping("/cancel/{id}")
@PreAuthorize("hasRole('ADMIN') or hasAuthority('admin') or hasRole('NORMAL') or hasAuthority('normal')")
@Operation(summary = "Cancel a booking by its ID")
@AuditLogged(action = "BOOKING_CANCELLED", resourceType = "BOOKING", resourceIdSpel = "#result.body.bookingId")
public ResponseEntity<BookingResponse> cancelBooking(@PathVariable Long id, Authentication authentication) {
Booking booking = bookingService.getBookingById(id);
if (booking == null) {
return ResponseEntity.notFound().build();
}

boolean isAdmin = authentication.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("admin"));
Expand All @@ -102,8 +93,6 @@ public ResponseEntity<BookingResponse> cancelBooking(@PathVariable Long id, Auth
}

BookingResponse response = bookingService.cancelBooking(id);
auditService.record("BOOKING_CANCELLED", authentication.getName(), "BOOKING",
String.valueOf(response.getBookingId()), response.getStatus().name(), response.getMessage());
return ResponseEntity.ok(response);
}
}
25 changes: 8 additions & 17 deletions src/main/java/com/cn/hotelDemo/controller/HotelController.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import com.cn.hotelDemo.dto.HotelRequest;
import com.cn.hotelDemo.model.Hotel;
import com.cn.hotelDemo.service.HotelService;
import com.cn.hotelDemo.service.AuditService;
import com.cn.hotelDemo.annotation.AuditLogged;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
Expand All @@ -34,29 +34,22 @@ public class HotelController {

@Autowired
HotelService hotelService;

@Autowired
AuditService auditService;

@GetMapping("/userDetail")
@Operation(summary = "Get current authenticated user details from OIDC")
@AuditLogged(action = "OIDC_PROFILE_VIEWED", resourceType = "AUTH", resourceIdSpel = "#oidcUser.email")
public String getDetails(@AuthenticationPrincipal OidcUser oidcUser) {
String details = "User name: %s, email: %s".formatted(oidcUser.getFullName(), oidcUser.getEmail());
auditService.record("OIDC_PROFILE_VIEWED", oidcUser.getEmail(), "AUTH", oidcUser.getEmail(), "SUCCESS",
"Authenticated profile details were requested");
return details;
return "User name: %s, email: %s".formatted(oidcUser.getFullName(), oidcUser.getEmail());
}

@PostMapping("/create")
@PreAuthorize("hasRole('ADMIN') or hasAuthority('admin')")
@Operation(summary = "Create a new hotel (Admin only)")
public void createHotel(@Valid @RequestBody HotelRequest hotelRequest, Authentication authentication)
@AuditLogged(action = "HOTEL_CREATED", resourceType = "HOTEL", resourceIdSpel = "#result.id")
public Hotel createHotel(@Valid @RequestBody HotelRequest hotelRequest, Authentication authentication)
{
Hotel createdHotel = hotelService.createHotel(hotelRequest);
auditService.record("HOTEL_CREATED", authentication.getName(), "HOTEL", String.valueOf(createdHotel.getId()),
"SUCCESS", "Hotel created with name=%s, city=%s".formatted(createdHotel.getName(),
createdHotel.getCity()));
}
return hotelService.createHotel(hotelRequest);
}

@GetMapping("/id/{id}")
@PreAuthorize("hasRole('NORMAL') or hasAuthority('normal')")
Expand All @@ -77,11 +70,9 @@ public List<Hotel> getAllHotels()
@DeleteMapping("/remove/id/{id}")
@PreAuthorize("hasRole('admin') or hasAuthority('admin')")
@Operation(summary = "Delete a hotel by ID (Admin only)")
@AuditLogged(action = "HOTEL_DELETED", resourceType = "HOTEL", resourceIdSpel = "#id")
public void deleteHotelById(@PathVariable Long id, Authentication authentication)
{
hotelService.deleteHotelById(id);
auditService.record("HOTEL_DELETED", authentication.getName(), "HOTEL", String.valueOf(id), "SUCCESS",
"Hotel delete requested for id=%s".formatted(id));

}
}
10 changes: 3 additions & 7 deletions src/main/java/com/cn/hotelDemo/controller/LoginController.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

import com.cn.hotelDemo.service.AuditService;
import com.cn.hotelDemo.annotation.AuditLogged;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
Expand All @@ -14,17 +14,13 @@
@Tag(name = "Login Controller", description = "Endpoints for authentication UI")
public class LoginController {

private final AuditService auditService;

public LoginController(AuditService auditService) {
this.auditService = auditService;
public LoginController() {
}

@GetMapping("/login")
@Operation(summary = "Serve the login page")
@AuditLogged(action = "LOGIN_PAGE_VIEWED", resourceType = "AUTH", resourceIdSpel = "#request.getRemoteAddr()")
public String login(HttpServletRequest request) {
auditService.record("LOGIN_PAGE_VIEWED", "anonymous", "AUTH", "/login", "SUCCESS",
"Login page rendered from %s".formatted(request.getRemoteAddr()));
return "login";
}
}
18 changes: 4 additions & 14 deletions src/main/java/com/cn/hotelDemo/controller/RoomController.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import com.cn.hotelDemo.dto.RoomRequest;
import com.cn.hotelDemo.model.Room;
import com.cn.hotelDemo.service.AuditService;
import com.cn.hotelDemo.annotation.AuditLogged;
import com.cn.hotelDemo.service.RoomService;

import jakarta.validation.Valid;
Expand All @@ -35,21 +35,16 @@
public class RoomController {

private final RoomService roomService;
private final AuditService auditService;

public RoomController(RoomService roomService, AuditService auditService) {
public RoomController(RoomService roomService) {
this.roomService = roomService;
this.auditService = auditService;
}

@PostMapping("/create")
@PreAuthorize("hasRole('ADMIN') or hasAuthority('admin')")
@Operation(summary = "Create a new room in a hotel")
@AuditLogged(action = "ROOM_CREATED", resourceType = "ROOM", resourceIdSpel = "#result.body.id")
public ResponseEntity<Room> createRoom(@Valid @RequestBody RoomRequest roomRequest, Authentication authentication) {
Room room = roomService.createRoom(roomRequest);
auditService.record("ROOM_CREATED", authentication.getName(), "ROOM", String.valueOf(room.getId()), "SUCCESS",
"Room created for hotelId=%s, roomNumber=%s".formatted(roomRequest.getHotelId(),
roomRequest.getRoomNumber()));
return new ResponseEntity<>(room, HttpStatus.CREATED);
}

Expand All @@ -63,15 +58,13 @@ public ResponseEntity<List<Room>> getRoomsByHotel(@PathVariable Long hotelId) {
@GetMapping("/hotel/{hotelId}/available")
@PreAuthorize("hasRole('ADMIN') or hasAuthority('admin') or hasRole('NORMAL') or hasAuthority('normal')")
@Operation(summary = "Check room availability between dates for a hotel")
@AuditLogged(action = "ROOM_AVAILABILITY_SEARCHED", resourceType = "ROOM", resourceIdSpel = "#hotelId")
public ResponseEntity<List<Room>> getAvailableRoomsByHotel(
@PathVariable Long hotelId,
Authentication authentication,
@RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate checkInDate,
@RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate checkOutDate) {
List<Room> rooms = roomService.getAvailableRoomsByHotelAndDates(hotelId, checkInDate, checkOutDate);
auditService.record("ROOM_AVAILABILITY_SEARCHED", authentication.getName(), "ROOM", String.valueOf(hotelId),
"SUCCESS", "Availability checked for hotelId=%s between %s and %s".formatted(hotelId, checkInDate,
checkOutDate));
return ResponseEntity.ok(rooms);
}

Expand All @@ -80,9 +73,6 @@ public ResponseEntity<List<Room>> getAvailableRoomsByHotel(
@Operation(summary = "Get a room by its ID")
public ResponseEntity<Room> getRoomById(@PathVariable Long id) {
Room room = roomService.getRoomById(id);
if (room == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(room);
}

Expand Down
17 changes: 5 additions & 12 deletions src/main/java/com/cn/hotelDemo/controller/UserController.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

import com.cn.hotelDemo.dto.UserRequest;
import com.cn.hotelDemo.model.User;
import com.cn.hotelDemo.service.AuditService;
import com.cn.hotelDemo.annotation.AuditLogged;
import com.cn.hotelDemo.service.UserService;

import io.swagger.v3.oas.annotations.Operation;
Expand All @@ -26,13 +26,9 @@
@RequestMapping("/user")
@Tag(name = "User Controller", description = "Endpoints for managing users")
public class UserController {

@Autowired
UserService userService;

@Autowired
AuditService auditService;

@GetMapping("/getUsers")
@Operation(summary = "Get all users")
@SecurityRequirement(name = "Bearer Authentication")
Expand All @@ -49,21 +45,18 @@ public User getUserById(@PathVariable Long id) {

@PostMapping("/createUser")
@Operation(summary = "Register a new user (Public)")
public void createUser(@Valid @RequestBody UserRequest userRequest)
@AuditLogged(action = "USER_CREATED", resourceType = "USER", resourceIdSpel = "#result.id")
public User createUser(@Valid @RequestBody UserRequest userRequest)
{
User createdUser = userService.createUser(userRequest);
auditService.record("USER_CREATED", userRequest.getUsername(), "USER", String.valueOf(createdUser.getId()),
"SUCCESS", "Public user registration completed for email=%s".formatted(userRequest.getEmail()));
return userService.createUser(userRequest);
}

@DeleteMapping("/remove/id/{id}")
@Operation(summary = "Delete a user by ID")
@SecurityRequirement(name = "Bearer Authentication")
@AuditLogged(action = "USER_DELETED", resourceType = "USER", resourceIdSpel = "#id")
public void deleteUserById(@PathVariable Long id)
{
userService.deleteUserById(id);
auditService.record("USER_DELETED", "system", "USER", String.valueOf(id), "SUCCESS",
"User delete requested for id=%s".formatted(id));

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.cn.hotelDemo.exception;

public class BookingNotFoundException extends RuntimeException {
public BookingNotFoundException(Long id) {
super("Booking not found with ID: " + id);
}
}
Loading
Loading