From 77dcfa3122e88b5d6e47067ee79be378776a5665 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 4 Mar 2026 17:01:54 -0700 Subject: [PATCH] Implement JWT authentication --- .../backend/controller/AuthController.java | 89 +++++++++++++++++++ .../backend/dto/auth/LoginRequest.java | 13 +++ .../backend/dto/auth/LoginResponse.java | 13 +++ .../backend/dto/auth/UserInfoResponse.java | 14 +++ .../java/com/petshop/backend/entity/User.java | 53 +++++++++++ .../backend/repository/UserRepository.java | 13 +++ .../security/JwtAuthenticationFilter.java | 58 ++++++++++++ .../com/petshop/backend/security/JwtUtil.java | 74 +++++++++++++++ .../backend/security/SecurityConfig.java | 71 +++++++++++++++ .../security/UserDetailsServiceImpl.java | 35 ++++++++ 10 files changed, 433 insertions(+) create mode 100644 backend/src/main/java/com/petshop/backend/controller/AuthController.java create mode 100644 backend/src/main/java/com/petshop/backend/dto/auth/LoginRequest.java create mode 100644 backend/src/main/java/com/petshop/backend/dto/auth/LoginResponse.java create mode 100644 backend/src/main/java/com/petshop/backend/dto/auth/UserInfoResponse.java create mode 100644 backend/src/main/java/com/petshop/backend/entity/User.java create mode 100644 backend/src/main/java/com/petshop/backend/repository/UserRepository.java create mode 100644 backend/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java create mode 100644 backend/src/main/java/com/petshop/backend/security/JwtUtil.java create mode 100644 backend/src/main/java/com/petshop/backend/security/SecurityConfig.java create mode 100644 backend/src/main/java/com/petshop/backend/security/UserDetailsServiceImpl.java diff --git a/backend/src/main/java/com/petshop/backend/controller/AuthController.java b/backend/src/main/java/com/petshop/backend/controller/AuthController.java new file mode 100644 index 00000000..4d0dea61 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/controller/AuthController.java @@ -0,0 +1,89 @@ +package com.petshop.backend.controller; + +import com.petshop.backend.dto.auth.LoginRequest; +import com.petshop.backend.dto.auth.LoginResponse; +import com.petshop.backend.dto.auth.UserInfoResponse; +import com.petshop.backend.entity.User; +import com.petshop.backend.repository.UserRepository; +import com.petshop.backend.security.JwtUtil; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthenticationManager authenticationManager; + private final UserRepository userRepository; + private final JwtUtil jwtUtil; + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + try { + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()) + ); + + User user = userRepository.findByUsername(request.getUsername()) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + UserDetails userDetails = new org.springframework.security.core.userdetails.User( + user.getUsername(), + user.getPassword(), + java.util.Collections.emptyList() + ); + + String token = jwtUtil.generateToken(userDetails); + + return ResponseEntity.ok(new LoginResponse( + token, + user.getUsername(), + user.getFullName(), + user.getRole().name() + )); + + } catch (BadCredentialsException e) { + Map error = new HashMap<>(); + error.put("message", "Invalid username or password"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error); + } + } + + @GetMapping("/me") + public ResponseEntity getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String username = authentication.getName(); + + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + return ResponseEntity.ok(new UserInfoResponse( + user.getId(), + user.getUsername(), + user.getFullName(), + user.getEmail(), + user.getRole().name() + )); + } + + @PostMapping("/logout") + public ResponseEntity logout() { + Map response = new HashMap<>(); + response.put("message", "Logged out successfully"); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/com/petshop/backend/dto/auth/LoginRequest.java b/backend/src/main/java/com/petshop/backend/dto/auth/LoginRequest.java new file mode 100644 index 00000000..c80971c3 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/auth/LoginRequest.java @@ -0,0 +1,13 @@ +package com.petshop.backend.dto.auth; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class LoginRequest { + @NotBlank(message = "Username is required") + private String username; + + @NotBlank(message = "Password is required") + private String password; +} diff --git a/backend/src/main/java/com/petshop/backend/dto/auth/LoginResponse.java b/backend/src/main/java/com/petshop/backend/dto/auth/LoginResponse.java new file mode 100644 index 00000000..d65b69f0 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/auth/LoginResponse.java @@ -0,0 +1,13 @@ +package com.petshop.backend.dto.auth; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class LoginResponse { + private String token; + private String username; + private String fullName; + private String role; +} diff --git a/backend/src/main/java/com/petshop/backend/dto/auth/UserInfoResponse.java b/backend/src/main/java/com/petshop/backend/dto/auth/UserInfoResponse.java new file mode 100644 index 00000000..b96a01b1 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/auth/UserInfoResponse.java @@ -0,0 +1,14 @@ +package com.petshop.backend.dto.auth; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class UserInfoResponse { + private Long id; + private String username; + private String fullName; + private String email; + private String role; +} diff --git a/backend/src/main/java/com/petshop/backend/entity/User.java b/backend/src/main/java/com/petshop/backend/entity/User.java new file mode 100644 index 00000000..5ac817f6 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/entity/User.java @@ -0,0 +1,53 @@ +package com.petshop.backend.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "users") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 50) + private String username; + + @Column(nullable = false) + private String password; + + @Column(name = "full_name", nullable = false, length = 100) + private String fullName; + + @Column(length = 100) + private String email; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; + + @Column(nullable = false) + private Boolean active = true; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + public enum Role { + STAFF, ADMIN + } +} diff --git a/backend/src/main/java/com/petshop/backend/repository/UserRepository.java b/backend/src/main/java/com/petshop/backend/repository/UserRepository.java new file mode 100644 index 00000000..95b79227 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/repository/UserRepository.java @@ -0,0 +1,13 @@ +package com.petshop.backend.repository; + +import com.petshop.backend.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + boolean existsByUsername(String username); +} diff --git a/backend/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java new file mode 100644 index 00000000..f08d4b5b --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java @@ -0,0 +1,58 @@ +package com.petshop.backend.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +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; + +import java.io.IOException; + +@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 { + final String authHeader = request.getHeader("Authorization"); + final String jwt; + final String username; + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + jwt = authHeader.substring(7); + username = jwtUtil.extractUsername(jwt); + + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + if (jwtUtil.validateToken(jwt, userDetails)) { + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + filterChain.doFilter(request, response); + } +} diff --git a/backend/src/main/java/com/petshop/backend/security/JwtUtil.java b/backend/src/main/java/com/petshop/backend/security/JwtUtil.java new file mode 100644 index 00000000..9381369b --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/security/JwtUtil.java @@ -0,0 +1,74 @@ +package com.petshop.backend.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +@Component +public class JwtUtil { + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.expiration}") + private Long expiration; + + private SecretKey getSigningKey() { + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + private Claims extractAllClaims(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private Boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + public String generateToken(UserDetails userDetails) { + Map claims = new HashMap<>(); + return createToken(claims, userDetails.getUsername()); + } + + private String createToken(Map claims, String subject) { + return Jwts.builder() + .claims(claims) + .subject(subject) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSigningKey()) + .compact(); + } + + public Boolean validateToken(String token, UserDetails userDetails) { + final String username = extractUsername(token); + return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); + } +} diff --git a/backend/src/main/java/com/petshop/backend/security/SecurityConfig.java b/backend/src/main/java/com/petshop/backend/security/SecurityConfig.java new file mode 100644 index 00000000..a846a8ef --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/security/SecurityConfig.java @@ -0,0 +1,71 @@ +package com.petshop.backend.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +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.core.userdetails.UserDetailsService; +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 +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthFilter; + private final UserDetailsService userDetailsService; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/auth/login").permitAll() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/dropdowns/suppliers").hasRole("ADMIN") + .requestMatchers("/api/v1/inventory/**").hasRole("ADMIN") + .requestMatchers("/api/v1/suppliers/**").hasRole("ADMIN") + .requestMatchers("/api/v1/product-suppliers/**").hasRole("ADMIN") + .requestMatchers("/api/v1/purchase-orders/**").hasRole("ADMIN") + .requestMatchers("/api/v1/users/**").hasRole("ADMIN") + .requestMatchers("/api/v1/analytics/**").hasRole("ADMIN") + .anyRequest().authenticated() + ) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authenticationProvider(authenticationProvider()) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/backend/src/main/java/com/petshop/backend/security/UserDetailsServiceImpl.java b/backend/src/main/java/com/petshop/backend/security/UserDetailsServiceImpl.java new file mode 100644 index 00000000..ec040275 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/security/UserDetailsServiceImpl.java @@ -0,0 +1,35 @@ +package com.petshop.backend.security; + +import com.petshop.backend.entity.User; +import com.petshop.backend.repository.UserRepository; +import lombok.RequiredArgsConstructor; +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; + +import java.util.Collections; + +@Service +@RequiredArgsConstructor +public class UserDetailsServiceImpl implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); + + if (!user.getActive()) { + throw new UsernameNotFoundException("User is inactive: " + username); + } + + return new org.springframework.security.core.userdetails.User( + user.getUsername(), + user.getPassword(), + Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())) + ); + } +}