Azure deployment setup #297

Closed
RecentRunner wants to merge 429 commits from azure-deploy into main
12 changed files with 456 additions and 4 deletions
Showing only changes of commit 933db5304f - Show all commits

View File

@@ -0,0 +1,84 @@
package com.petshop.backend.config;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.AppPrincipal;
import com.petshop.backend.service.ActivityLogService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@Order(Ordered.LOWEST_PRECEDENCE - 20)
public class ActivityLoggingFilter extends OncePerRequestFilter {
private final UserRepository userRepository;
private final ActivityLogService activityLogService;
public ActivityLoggingFilter(UserRepository userRepository, ActivityLogService activityLogService) {
this.userRepository = userRepository;
this.activityLogService = activityLogService;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI();
if (uri == null || uri.isBlank()) {
return true;
}
if (!uri.startsWith("/api/")) {
return true;
}
String lower = uri.toLowerCase(java.util.Locale.ROOT);
return lower.startsWith("/api/v1/health")
|| lower.startsWith("/api/v1/activity-logs")
|| lower.startsWith("/v3/api-docs")
|| lower.startsWith("/swagger-ui")
|| lower.startsWith("/ws/");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
filterChain.doFilter(request, response);
recordActivity(request, response);
}
private void recordActivity(HttpServletRequest request, HttpServletResponse response) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return;
}
Long userId = null;
Object principal = authentication.getPrincipal();
if (principal instanceof AppPrincipal appPrincipal) {
userId = appPrincipal.getUserId();
} else if (authentication instanceof UsernamePasswordAuthenticationToken token
&& token.getPrincipal() instanceof AppPrincipal appPrincipal) {
userId = appPrincipal.getUserId();
}
if (userId == null) {
return;
}
User user = userRepository.findById(userId).orElse(null);
if (user == null) {
return;
}
String activity = String.format("%s %s -> %d", request.getMethod(), request.getRequestURI(), response.getStatus());
activityLogService.record(user, activity);
}
}

View File

@@ -0,0 +1,16 @@
package com.petshop.backend.config;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ActivityLoggingFilterRegistrationConfig {
@Bean
public FilterRegistrationBean<ActivityLoggingFilter> activityLoggingFilterRegistration(ActivityLoggingFilter activityLoggingFilter) {
FilterRegistrationBean<ActivityLoggingFilter> registrationBean = new FilterRegistrationBean<>(activityLoggingFilter);
registrationBean.setEnabled(false);
return registrationBean;
}
}

View File

@@ -0,0 +1,30 @@
package com.petshop.backend.controller;
import com.petshop.backend.dto.activity.ActivityLogResponse;
import com.petshop.backend.service.ActivityLogService;
import java.util.List;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/activity-logs")
@PreAuthorize("hasRole('ADMIN')")
public class ActivityLogController {
private final ActivityLogService activityLogService;
public ActivityLogController(ActivityLogService activityLogService) {
this.activityLogService = activityLogService;
}
@GetMapping
public ResponseEntity<List<ActivityLogResponse>> getActivityLogs(
@RequestParam(defaultValue = "2000") int limit) {
int safeLimit = Math.min(Math.max(1, limit), 10000);
return ResponseEntity.ok(activityLogService.getLogs(safeLimit));
}
}

View File

@@ -11,6 +11,7 @@ import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.JwtUtil;
import com.petshop.backend.service.ActivityLogService;
import com.petshop.backend.service.AvatarStorageService;
import com.petshop.backend.util.AuthenticationHelper;
import com.petshop.backend.util.PhoneUtils;
@@ -29,6 +30,8 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.HashMap;
@@ -38,18 +41,22 @@ import java.util.Map;
@RequestMapping("/api/v1/auth")
public class AuthController {
private static final Logger log = LoggerFactory.getLogger(AuthController.class);
private final AuthenticationManager authenticationManager;
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
private final PasswordEncoder passwordEncoder;
private final AvatarStorageService avatarStorageService;
private final ActivityLogService activityLogService;
public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService) {
public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService) {
this.authenticationManager = authenticationManager;
this.userRepository = userRepository;
this.jwtUtil = jwtUtil;
this.passwordEncoder = passwordEncoder;
this.avatarStorageService = avatarStorageService;
this.activityLogService = activityLogService;
}
@PostMapping("/register")
@@ -60,18 +67,21 @@ public class AuthController {
String phone = normalizePhone(request.getPhone());
if (userRepository.findByUsername(username).isPresent()) {
log.warn("Registration rejected: username already exists ({})", username);
Map<String, String> error = new HashMap<>();
error.put("message", "Username already exists");
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
if (userRepository.findByEmail(email).isPresent()) {
log.warn("Registration rejected: email already exists");
Map<String, String> error = new HashMap<>();
error.put("message", "Email already exists");
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
if (phone != null && userRepository.findByPhone(phone).isPresent()) {
log.warn("Registration rejected: phone already exists");
Map<String, String> error = new HashMap<>();
error.put("message", "Phone already exists");
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
@@ -91,6 +101,7 @@ public class AuthController {
User savedUser = userRepository.save(user);
String token = jwtUtil.generateToken(savedUser);
activityLogService.record(savedUser, "POST /api/v1/auth/register -> 201");
return ResponseEntity.status(HttpStatus.CREATED).body(new RegisterResponse(
savedUser.getId(),
@@ -113,6 +124,7 @@ public class AuthController {
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
String token = jwtUtil.generateToken(user);
activityLogService.record(user, "POST /api/v1/auth/login -> 200");
return ResponseEntity.ok(new LoginResponse(
token,
@@ -121,11 +133,13 @@ public class AuthController {
));
} catch (BadCredentialsException e) {
log.warn("Login failed for username {}", request.getUsername());
Map<String, String> error = new HashMap<>();
error.put("message", "Invalid username or password");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
} catch (InternalAuthenticationServiceException e) {
if (e.getCause() instanceof DisabledException disabledException) {
log.warn("Login denied for disabled user {}", request.getUsername());
Map<String, String> error = new HashMap<>();
error.put("message", disabledException.getMessage());
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);

View File

@@ -0,0 +1,126 @@
package com.petshop.backend.dto.activity;
import java.time.LocalDateTime;
public class ActivityLogResponse {
private Long logId;
private Long userId;
private String username;
private String fullName;
private String role;
private Long storeId;
private String storeName;
private String usernameSnapshot;
private String fullNameSnapshot;
private String roleSnapshot;
private String storeNameSnapshot;
private String activity;
private LocalDateTime logTimestamp;
public ActivityLogResponse() {
}
public Long getLogId() {
return logId;
}
public void setLogId(Long logId) {
this.logId = logId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public Long getStoreId() {
return storeId;
}
public void setStoreId(Long storeId) {
this.storeId = storeId;
}
public String getStoreName() {
return storeName;
}
public void setStoreName(String storeName) {
this.storeName = storeName;
}
public String getUsernameSnapshot() {
return usernameSnapshot;
}
public void setUsernameSnapshot(String usernameSnapshot) {
this.usernameSnapshot = usernameSnapshot;
}
public String getFullNameSnapshot() {
return fullNameSnapshot;
}
public void setFullNameSnapshot(String fullNameSnapshot) {
this.fullNameSnapshot = fullNameSnapshot;
}
public String getRoleSnapshot() {
return roleSnapshot;
}
public void setRoleSnapshot(String roleSnapshot) {
this.roleSnapshot = roleSnapshot;
}
public String getStoreNameSnapshot() {
return storeNameSnapshot;
}
public void setStoreNameSnapshot(String storeNameSnapshot) {
this.storeNameSnapshot = storeNameSnapshot;
}
public String getActivity() {
return activity;
}
public void setActivity(String activity) {
this.activity = activity;
}
public LocalDateTime getLogTimestamp() {
return logTimestamp;
}
public void setLogTimestamp(LocalDateTime logTimestamp) {
this.logTimestamp = logTimestamp;
}
}

View File

@@ -21,6 +21,18 @@ public class ActivityLog {
@JoinColumn(name = "storeId")
private StoreLocation store;
@Column(length = 50)
private String usernameSnapshot;
@Column(length = 100)
private String fullNameSnapshot;
@Column(length = 20)
private String roleSnapshot;
@Column(length = 100)
private String storeNameSnapshot;
@Column(nullable = false, columnDefinition = "TEXT")
private String activity;
@@ -61,6 +73,38 @@ public class ActivityLog {
this.store = store;
}
public String getUsernameSnapshot() {
return usernameSnapshot;
}
public void setUsernameSnapshot(String usernameSnapshot) {
this.usernameSnapshot = usernameSnapshot;
}
public String getFullNameSnapshot() {
return fullNameSnapshot;
}
public void setFullNameSnapshot(String fullNameSnapshot) {
this.fullNameSnapshot = fullNameSnapshot;
}
public String getRoleSnapshot() {
return roleSnapshot;
}
public void setRoleSnapshot(String roleSnapshot) {
this.roleSnapshot = roleSnapshot;
}
public String getStoreNameSnapshot() {
return storeNameSnapshot;
}
public void setStoreNameSnapshot(String storeNameSnapshot) {
this.storeNameSnapshot = storeNameSnapshot;
}
public String getActivity() {
return activity;
}

View File

@@ -1,9 +1,16 @@
package com.petshop.backend.repository;
import com.petshop.backend.entity.ActivityLog;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ActivityLogRepository extends JpaRepository<ActivityLog, Long> {
boolean existsByUser_Id(Long userId);
@Query("select a from ActivityLog a order by a.logTimestamp desc, a.logId desc")
List<ActivityLog> findRecent(Pageable pageable);
}

View File

@@ -8,12 +8,16 @@ import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
@Component
public class RestAccessDeniedHandler implements AccessDeniedHandler {
private static final Logger log = LoggerFactory.getLogger(RestAccessDeniedHandler.class);
private final ApiErrorResponder apiErrorResponder;
public RestAccessDeniedHandler(ApiErrorResponder apiErrorResponder) {
@@ -22,6 +26,7 @@ public class RestAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
log.warn("Access denied: {} {} - {}", request.getMethod(), request.getRequestURI(), accessDeniedException.getMessage());
apiErrorResponder.write(
response,
HttpStatus.FORBIDDEN,

View File

@@ -8,12 +8,16 @@ import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
private static final Logger log = LoggerFactory.getLogger(RestAuthenticationEntryPoint.class);
private final ApiErrorResponder apiErrorResponder;
public RestAuthenticationEntryPoint(ApiErrorResponder apiErrorResponder) {
@@ -22,6 +26,7 @@ public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
log.warn("Unauthorized request: {} {} - {}", request.getMethod(), request.getRequestURI(), authException.getMessage());
apiErrorResponder.write(
response,
HttpStatus.UNAUTHORIZED,

View File

@@ -21,6 +21,8 @@ import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import com.petshop.backend.config.ActivityLoggingFilter;
import java.util.List;
@Configuration
@@ -44,7 +46,7 @@ public class SecurityConfig {
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
public SecurityFilterChain securityFilterChain(HttpSecurity http, ActivityLoggingFilter activityLoggingFilter) throws Exception {
http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
@@ -65,6 +67,7 @@ public class SecurityConfig {
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authenticationProvider(daoAuthenticationProvider())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
http.addFilterAfter(activityLoggingFilter, JwtAuthenticationFilter.class);
return http.build();
}
@@ -97,7 +100,7 @@ public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,109 @@
package com.petshop.backend.service;
import com.petshop.backend.dto.activity.ActivityLogResponse;
import com.petshop.backend.entity.ActivityLog;
import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.ActivityLogRepository;
import com.petshop.backend.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class ActivityLogService {
private static final Logger log = LoggerFactory.getLogger(ActivityLogService.class);
private final ActivityLogRepository activityLogRepository;
private final UserRepository userRepository;
public ActivityLogService(ActivityLogRepository activityLogRepository, UserRepository userRepository) {
this.activityLogRepository = activityLogRepository;
this.userRepository = userRepository;
}
@Transactional
public void record(User user, String activity) {
if (user == null || activity == null || activity.isBlank()) {
return;
}
try {
User managedUser = userRepository.findById(user.getId()).orElse(user);
StoreLocation store = managedUser.getPrimaryStore();
ActivityLog entry = new ActivityLog();
entry.setUser(managedUser);
entry.setStore(store);
entry.setUsernameSnapshot(managedUser.getUsername());
entry.setFullNameSnapshot(resolveFullName(managedUser));
entry.setRoleSnapshot(managedUser.getRole() != null ? managedUser.getRole().name() : null);
entry.setStoreNameSnapshot(store != null ? store.getStoreName() : null);
entry.setActivity(activity.trim());
activityLogRepository.save(entry);
} catch (Exception ex) {
log.warn("Failed to persist activity log", ex);
}
}
@Transactional(readOnly = true)
public List<ActivityLogResponse> getLogs(int limit) {
return activityLogRepository.findRecent(PageRequest.of(0, limit)).stream().map(this::toResponse).toList();
}
private ActivityLogResponse toResponse(ActivityLog entry) {
ActivityLogResponse response = new ActivityLogResponse();
response.setLogId(entry.getLogId());
if (entry.getUser() != null) {
response.setUserId(entry.getUser().getId());
response.setUsername(firstNonBlank(entry.getUsernameSnapshot(), entry.getUser().getUsername()));
response.setFullName(firstNonBlank(entry.getFullNameSnapshot(), resolveFullName(entry.getUser())));
response.setRole(firstNonBlank(entry.getRoleSnapshot(), entry.getUser().getRole() != null ? entry.getUser().getRole().name() : null));
}
StoreLocation store = entry.getStore();
if (store != null) {
response.setStoreId(store.getStoreId());
response.setStoreName(firstNonBlank(entry.getStoreNameSnapshot(), store.getStoreName()));
}
response.setUsernameSnapshot(entry.getUsernameSnapshot());
response.setFullNameSnapshot(entry.getFullNameSnapshot());
response.setRoleSnapshot(entry.getRoleSnapshot());
response.setStoreNameSnapshot(entry.getStoreNameSnapshot());
response.setActivity(entry.getActivity());
response.setLogTimestamp(entry.getLogTimestamp());
return response;
}
private String resolveFullName(User user) {
if (user == null) {
return null;
}
if (user.getFullName() != null && !user.getFullName().isBlank()) {
return user.getFullName();
}
String first = user.getFirstName();
String last = user.getLastName();
if (first == null || first.isBlank()) {
return last;
}
if (last == null || last.isBlank()) {
return first;
}
return first.trim() + " " + last.trim();
}
private String firstNonBlank(String preferred, String fallback) {
if (preferred != null && !preferred.isBlank()) {
return preferred;
}
return fallback;
}
}

View File

@@ -3,6 +3,7 @@ package com.petshop.backend.service;
import com.petshop.backend.dto.common.BulkDeleteRequest;
import com.petshop.backend.dto.user.UserRequest;
import com.petshop.backend.dto.user.UserResponse;
import com.petshop.backend.repository.ActivityLogRepository;
import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User;
import com.petshop.backend.exception.ResourceNotFoundException;
@@ -25,11 +26,13 @@ import static org.springframework.http.HttpStatus.CONFLICT;
public class UserService {
private final UserRepository userRepository;
private final ActivityLogRepository activityLogRepository;
private final PasswordEncoder passwordEncoder;
private final StoreRepository storeRepository;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, StoreRepository storeRepository) {
public UserService(UserRepository userRepository, ActivityLogRepository activityLogRepository, PasswordEncoder passwordEncoder, StoreRepository storeRepository) {
this.userRepository = userRepository;
this.activityLogRepository = activityLogRepository;
this.passwordEncoder = passwordEncoder;
this.storeRepository = storeRepository;
}
@@ -121,11 +124,17 @@ public class UserService {
if (!userRepository.existsById(id)) {
throw new ResourceNotFoundException("User not found with id: " + id);
}
if (activityLogRepository.existsByUser_Id(id)) {
throw new ResponseStatusException(CONFLICT, "User cannot be deleted because activity logs exist");
}
userRepository.deleteById(id);
}
@Transactional
public void bulkDeleteUsers(BulkDeleteRequest request) {
if (request.getIds() != null && request.getIds().stream().anyMatch(activityLogRepository::existsByUser_Id)) {
throw new ResponseStatusException(CONFLICT, "One or more users cannot be deleted because activity logs exist");
}
userRepository.deleteAllById(request.getIds());
}