diff --git a/.gitignore b/.gitignore index 667aaef..2d20004 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ target/ .settings .springBeans .sts4-cache +.env ### IntelliJ IDEA ### .idea diff --git a/README.md b/README.md new file mode 100644 index 0000000..c6c4117 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# MV-API + +A Spring Boot 3.5.5 RESTful API application that manages online pet adoption +through relationships between users, applications, and admin management. +Includes registration, login, and secured endpoints, with support for role-based access. + + +--- + +## ๐Ÿš€ Features +- User registration & login +- Authentication & Authorization with **Auth0 and OAuth**. +- Ownership enforcement +- Password hashing with **BCrypt** +- JWT access & refresh tokens +- Global exception handling +- H2 database for dev/test +- CORS configuration for frontend (React/Vue/Angular) +- Actuator endpoints for health checks + +--- + +## ๐Ÿ“ฆ Tech Stack +- **Java 21** +- **Maven** +- **Spring Boot 3.5.5** + - Web + - Data JPA + - Security + - Validation + - Actuator +- **JJWT 0.11.5** +- **H2 Database** (dev/test) + +--- + +## โš™๏ธ Getting Started + +### Prerequisites +- Java 21 +- Maven 3.9+ +- VS Code or IntelliJ IDEA +- Cloned repository + +### Build & Run +```bash +./mvnw clean install +./mvnw spring-boot:run +App will be available at ๐Ÿ‘‰ http://localhost:8080 + +๐Ÿ”‘ Authentication Flow +1. Register a User +http +Copy code +POST /api/auth/register +Content-Type: application/json + +{ + "email": "user@example.com", + "username": "testuser", + "password": "password123" +} +2. Login +http +Copy code +POST /api/auth/login +Content-Type: application/json + +{ + "email": "user@example.com", + "password": "password123" +} +โœ”๏ธ Returns a JWT token. + +3. Call Protected Endpoint +http +Copy code +GET /api/users/me +Authorization: Bearer +โš™๏ธ Configuration +Edit src/main/resources/application.properties: + +properties +Copy code +spring.application.name=MV-API + +# JWT +security.jwt.secret=mysupersecretkeymysupersecretkey123! +security.jwt.expMinutes=60 +security.jwt.refreshDays=14 + +# CORS +app.cors.allowed-origins=http://localhost:5173,http://localhost:3000 +๐Ÿณ Docker +Build & run in Docker: + +bash +Copy code +docker build -t mv-api . +docker run -p 8080:8080 mv-api +๐Ÿ“‚ Project Structure +graphql +Copy code +src/main/java/com/coffeecodesyndicate/api + โ”œโ”€โ”€ config/ # Security, JWT, exception handling + โ”œโ”€โ”€ controllers/ # REST controllers (Auth, User, Admin, etc.) + โ”œโ”€โ”€ dto/ # DTOs for requests & responses + โ”œโ”€โ”€ models/ # Entities (User, Application, Pet, etc.) + โ”œโ”€โ”€ repositories/ # Spring Data JPA repositories + โ”œโ”€โ”€ services/ # Business logic + โ””โ”€โ”€ MvApiApplication.java # Main entry point +๐Ÿงช Testing +bash +Copy code +./mvnw test diff --git a/pom.xml b/pom.xml index d40c309..08a361d 100644 --- a/pom.xml +++ b/pom.xml @@ -2,72 +2,74 @@ 4.0.0 + org.springframework.boot spring-boot-starter-parent 3.5.5 + com.coffeecodesyndicate api 0.0.1-SNAPSHOT MV-API Demo project for Spring Boot - - - - - - - - - - - - - + 21 - + + org.springframework.boot spring-boot-starter-actuator + org.springframework.boot spring-boot-starter + org.springframework.boot - spring-boot-starter-test - test + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database h2 runtime + + org.springframework.boot - spring-boot-starter-data-jpa - - - org.springframework.boot - spring-boot-starter-web + spring-boot-starter-security + + - org.mindrot - jbcrypt - 0.4 + com.okta.spring + okta-spring-boot-starter + 3.0.5 org.springframework.boot - spring-boot-starter-oauth2-client + spring-boot-starter-validation + + io.jsonwebtoken jjwt-api @@ -85,6 +87,14 @@ 0.11.5 runtime + + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + org.springframework.boot spring-boot-starter-thymeleaf @@ -93,10 +103,29 @@ org.thymeleaf.extras thymeleaf-extras-springsecurity6 + + org.springframework.boot + spring-boot-starter-security + nz.net.ultraq.thymeleaf thymeleaf-layout-dialect + + + + org.springframework.boot + spring-boot-starter-test + test + + + @@ -107,5 +136,4 @@ - diff --git a/src/main/java/com/coffeecodesyndicate/api/config/ConflictException.java b/src/main/java/com/coffeecodesyndicate/api/config/ConflictException.java new file mode 100644 index 0000000..18be09a --- /dev/null +++ b/src/main/java/com/coffeecodesyndicate/api/config/ConflictException.java @@ -0,0 +1,16 @@ +package com.coffeecodesyndicate.api.config; + +/** + * Custom exception to represent 409 Conflict errors, + * e.g., when a username or email is already in use. + */ +public class ConflictException extends RuntimeException { + + public ConflictException(String message) { + super(message); + } + + public ConflictException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/coffeecodesyndicate/api/config/DataSeeder.java b/src/main/java/com/coffeecodesyndicate/api/config/DataSeeder.java new file mode 100644 index 0000000..d149902 --- /dev/null +++ b/src/main/java/com/coffeecodesyndicate/api/config/DataSeeder.java @@ -0,0 +1,157 @@ +package com.coffeecodesyndicate.api.config; + +import java.time.LocalDate; + +import com.coffeecodesyndicate.api.models.Application; +import com.coffeecodesyndicate.api.models.ApplicationStatus; +import com.coffeecodesyndicate.api.models.Pet; +import com.coffeecodesyndicate.api.models.User; +import com.coffeecodesyndicate.api.repositories.ApplicationRepository; +import com.coffeecodesyndicate.api.repositories.PetRepository; +import com.coffeecodesyndicate.api.repositories.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +@Component + +public class DataSeeder implements CommandLineRunner { + private final PetRepository petRepository; + private final UserRepository userRepository; + private final ApplicationRepository applicationRepository; + + @Autowired + public DataSeeder(PetRepository petRepository, UserRepository userRepository, ApplicationRepository applicationRepository){ + this.petRepository = petRepository; + this.userRepository = userRepository; + this.applicationRepository = applicationRepository; + } + + @Override + public void run(String...args) throws Exception { + User registered = new User(); + registered.setUsername("lukePowell8"); + registered.setPassword("pass123#"); + registered.setEmail("powell8@example.com"); + registered.setIsRegistered(true); + registered.setIsAdmin(false); + + User admin = new User(); + admin.setUsername("adminUser"); + admin.setPassword("$2a$10$Xb2YjK1"); + admin.setEmail("admin@pets.com"); + admin.setIsRegistered(true); + admin.setIsAdmin(true); + + User registered2 = new User(); + registered2.setUsername("janeDoe"); + registered2.setPassword("$2a$10$mkdL"); + registered2.setEmail("petLover@example.com"); + registered2.setIsRegistered(true); + registered2.setIsAdmin(false); + + User registered3 = new User(); + registered3.setUsername("billBrown"); + registered3.setPassword("dogs4Life"); + registered3.setEmail("brownFamily@example.com"); + registered3.setIsRegistered(true); + registered3.setIsAdmin(false); + + User registered4 = new User(); + registered4.setUsername("paisClanton"); + registered4.setPassword("p$2015"); + registered4.setEmail("Paisley@example.com"); + registered4.setIsRegistered(true); + registered4.setIsAdmin(false); + + userRepository.save(admin); + userRepository.save(registered); + userRepository.save(registered2); + userRepository.save(registered3); + userRepository.save(registered4); + + Pet pet1 = new Pet(); + pet1.setName("Buddy"); + pet1.setBreed("Labrador"); + pet1.setAge(3); + pet1.setDescription("Friendly dog"); + pet1.setIsAdopted(true); + pet1.setOwner(registered3); + + Pet pet2 = new Pet(); + pet2.setName("Whiskers"); + pet2.setBreed("Tabby"); + pet2.setAge(2); + pet2.setDescription("Playful cat"); + pet2.setIsAdopted(true); + pet2.setOwner(registered4); + + Pet pet3 = new Pet(); + pet3.setName("Duke"); + pet3.setBreed("Boxer"); + pet3.setAge(6); + pet3.setDescription("Gentle giant, gets along with everyone."); + pet3.setIsAdopted(false); + pet3.setOwner(null); + + Pet pet4 = new Pet(); + pet4.setName("Oliver"); + pet4.setBreed("Maine Coon"); + pet4.setAge(1); + pet4.setDescription("Playful kitten, loves climbing and chasing toys."); + pet4.setIsAdopted(false); + pet4.setOwner(null); + + Pet pet5 = new Pet(); + pet5.setName("Molly"); + pet5.setBreed("Beagle"); + pet5.setAge(4); + pet5.setDescription("Energetic and loves walks."); + pet5.setIsAdopted(true); + pet5.setOwner(registered2); + + petRepository.save(pet1); + petRepository.save(pet2); + petRepository.save(pet3); + petRepository.save(pet4); + petRepository.save(pet5); + + + Application app1 = new Application(); + app1.setStatus(ApplicationStatus.PENDING); // assuming you have PENDING, APPROVED, REJECTED in your enum + app1.setApplicationDate(LocalDate.now().minusDays(3)); + app1.setFormTitle("Adoption for Duke"); + app1.setFormBody("I'm looking for a longtime companion for my family."); + app1.setUser(registered2); // assign to an existing user + app1.setPet(pet3); // assign to an existing pet + + Application app2 = new Application(); + app2.setStatus(ApplicationStatus.APPROVED); + app2.setApplicationDate(LocalDate.now().minusDays(10)); + app2.setFormTitle("Adoption for Whiskers"); + app2.setFormBody("I have shown how responsible I was this school year. Mom said I could get a kitty."); + app2.setUser(registered4); + app2.setPet(pet2); + + Application app3 = new Application(); + app3.setStatus(ApplicationStatus.REJECTED); + app3.setApplicationDate(LocalDate.now().minusDays(1)); + app3.setFormTitle("Adoption for Oliver"); + app3.setFormBody("Have always wanted a Maine Coon and have experience with cats."); + app3.setUser(registered); + app3.setPet(pet4); + + applicationRepository.save(app1); + applicationRepository.save(app2); + applicationRepository.save(app3); + + + + + + + } + + +} + diff --git a/src/main/java/com/coffeecodesyndicate/api/config/ExcludeBrokenControllersConfig.java b/src/main/java/com/coffeecodesyndicate/api/config/ExcludeBrokenControllersConfig.java new file mode 100644 index 0000000..a02ca3c --- /dev/null +++ b/src/main/java/com/coffeecodesyndicate/api/config/ExcludeBrokenControllersConfig.java @@ -0,0 +1,17 @@ +// src/main/java/com/coffeecodesyndicate/api/config/ExcludeBrokenControllersConfig.java +package com.coffeecodesyndicate.api.config; + +import com.coffeecodesyndicate.api.controllers.AdminController; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; + +@Configuration +@ComponentScan( + basePackages = "com.coffeecodesyndicate.api", + excludeFilters = @ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = AdminController.class + ) +) +public class ExcludeBrokenControllersConfig {} diff --git a/src/main/java/com/coffeecodesyndicate/api/config/GlobalExceptionHandler.java b/src/main/java/com/coffeecodesyndicate/api/config/GlobalExceptionHandler.java new file mode 100644 index 0000000..118fc92 --- /dev/null +++ b/src/main/java/com/coffeecodesyndicate/api/config/GlobalExceptionHandler.java @@ -0,0 +1,90 @@ +package com.coffeecodesyndicate.api.config; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + /* ---------- 409: Conflicts (duplicates, unique key, etc.) ---------- */ + @ExceptionHandler(ConflictException.class) + public ResponseEntity handleConflict(ConflictException ex, HttpServletRequest req) { + return build(HttpStatus.CONFLICT, "Conflict", ex.getMessage(), req); + } + + // If DB unique constraints fire (e.g., uk_users_email), map to 409 as well + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrity(DataIntegrityViolationException ex, HttpServletRequest req) { + String msg = "Data integrity violation"; + if (ex.getMostSpecificCause() != null && ex.getMostSpecificCause().getMessage() != null) { + msg = ex.getMostSpecificCause().getMessage(); + } + return build(HttpStatus.CONFLICT, "Conflict", msg, req); + } + + /* ---------- 401: Authentication failures ---------- */ + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleBadCredentials(BadCredentialsException ex, HttpServletRequest req) { + return build(HttpStatus.UNAUTHORIZED, "Unauthorized", "Invalid credentials", req); + } + + /* ---------- 400: Validation errors (@Valid) ---------- */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex, HttpServletRequest req) { + Map errors = new HashMap<>(); + for (FieldError fe : ex.getBindingResult().getFieldErrors()) { + errors.put(fe.getField(), fe.getDefaultMessage()); + } + String message = "Validation failed"; + return build(HttpStatus.BAD_REQUEST, "Bad Request", message, req, errors); + } + + /* ---------- 500: Fallback ---------- */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleOther(Exception ex, HttpServletRequest req) { + return build(HttpStatus.INTERNAL_SERVER_ERROR, "Internal Server Error", ex.getMessage(), req); + } + + /* ---------- Helpers ---------- */ + private ResponseEntity build(HttpStatus status, String error, String message, HttpServletRequest req) { + return ResponseEntity.status(status) + .body(new ErrorResponse(Instant.now(), status.value(), error, message, req.getRequestURI(), null)); + } + + private ResponseEntity build(HttpStatus status, String error, String message, + HttpServletRequest req, Map details) { + return ResponseEntity.status(status) + .body(new ErrorResponse(Instant.now(), status.value(), error, message, req.getRequestURI(), details)); + } + + /* Small JSON error payload */ + public static class ErrorResponse { + public final Instant timestamp; + public final int status; + public final String error; + public final String message; + public final String path; + public final Map details; // optional per-field errors + + public ErrorResponse(Instant timestamp, int status, String error, String message, + String path, Map details) { + this.timestamp = timestamp; + this.status = status; + this.error = error; + this.message = message; + this.path = path; + this.details = details; + } + } +} diff --git a/src/main/java/com/coffeecodesyndicate/api/config/JService.java b/src/main/java/com/coffeecodesyndicate/api/config/JService.java new file mode 100644 index 0000000..9549245 --- /dev/null +++ b/src/main/java/com/coffeecodesyndicate/api/config/JService.java @@ -0,0 +1,72 @@ +package com.coffeecodesyndicate.api.config; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.security.Key; +import java.util.Date; +import java.util.function.Function; + +/** + * Service for creating and validating JWT tokens. + */ +@Service +public class JService { + + private final Key signingKey; + private final long expirationMillis; + + public JService( + @Value("${security.jwt.secret}") String secret, + @Value("${security.jwt.expMinutes:60}") long expMinutes + ) { + this.signingKey = Keys.hmacShaKeyFor(secret.getBytes()); + this.expirationMillis = expMinutes * 60_000L; + } + + /** + * Generate a JWT token for the given subject (usually the user's email). + */ + public String generateToken(String subject) { + long now = System.currentTimeMillis(); + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(new Date(now)) + .setExpiration(new Date(now + expirationMillis)) + .signWith(signingKey, SignatureAlgorithm.HS256) + .compact(); + } + + /** + * Extract the subject (e.g. email) from the token. + */ + public String extractSubject(String token) { + return extractClaim(token, Claims::getSubject); + } + + /** + * Validate token for subject and expiration. + */ + public boolean isTokenValid(String token, String expectedSubject) { + String subject = extractSubject(token); + return subject.equalsIgnoreCase(expectedSubject) && !isTokenExpired(token); + } + + /* ---------- Internal helpers ---------- */ + + private boolean isTokenExpired(String token) { + Date expiration = extractClaim(token, Claims::getExpiration); + return expiration.before(new Date()); + } + + private T extractClaim(String token, Function resolver) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(signingKey) + .build() + .parseClaimsJws(token) + .getBody(); + return resolver.apply(claims); + } +} diff --git a/src/main/java/com/coffeecodesyndicate/api/config/SecurityConfig.java b/src/main/java/com/coffeecodesyndicate/api/config/SecurityConfig.java index e85a614..fd441eb 100644 --- a/src/main/java/com/coffeecodesyndicate/api/config/SecurityConfig.java +++ b/src/main/java/com/coffeecodesyndicate/api/config/SecurityConfig.java @@ -1,78 +1,120 @@ package com.coffeecodesyndicate.api.config; import com.coffeecodesyndicate.api.repositories.UserRepository; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; + +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.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; + import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.User; + import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; + import org.springframework.security.web.SecurityFilterChain; -import java.util.Collections; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.List; @Configuration -@EnableWebSecurity +@EnableMethodSecurity public class SecurityConfig { - private final UserRepository userRepository; + @Bean + public UserDetailsService userDetailsService(UserRepository repo) { + // authenticate by EMAIL (case-insensitive) + return email -> repo.findByEmailIgnoreCase(email) + .map(u -> User.withUsername(u.getEmail()) + .password(u.getPasswordHash()) // BCrypt hash from DB + .accountLocked(Boolean.TRUE.equals(u.getLocked())) + .disabled(!Boolean.TRUE.equals(u.getEnabled())) + .roles(Boolean.TRUE.equals(u.getIsAdmin()) ? "ADMIN" : "USER") + .build()) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + email)); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); // BCrypt + } + + @Bean + + public AuthenticationProvider authenticationProvider( + UserDetailsService uds, PasswordEncoder encoder) { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setUserDetailsService(uds); + provider.setPasswordEncoder(encoder); + return provider; + } - public SecurityConfig(UserRepository userRepository) { - this.userRepository = userRepository; + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception { + return cfg.getAuthenticationManager(); } @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + AuthenticationProvider authProvider, + CorsConfigurationSource corsConfigurationSource) throws Exception { + http - .csrf(csrf -> csrf.disable()) - .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/unregistered/register").permitAll() - .requestMatchers("/unregistered/**").permitAll() - .anyRequest().authenticated() - ) - .httpBasic(); + .csrf(csrf -> csrf.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource)) + .authenticationProvider(authProvider) + .authorizeHttpRequests(auth -> auth + // keep your existing public routes + .requestMatchers("/unregistered/register").permitAll() + .requestMatchers("/unregistered/**").permitAll() + // add auth/register/login if you create them later + .requestMatchers("/api/auth/**").permitAll() + .anyRequest().authenticated() + ) + // ok to keep httpBasic() for now; you can remove when you switch to JWT filter + .httpBasic(basic -> {}) + .formLogin(form -> form.disable()) + .oauth2Login(oauth2 -> {}) + .sessionManagement(sm -> sm.sessionCreationPolicy( + org.springframework.security.config.http.SessionCreationPolicy.STATELESS + )); return http.build(); } - // This bean creates the PasswordEncoder instance that UserService needs. @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } + public CorsConfigurationSource corsConfigurationSource( + @Value("${app.cors.allowed-origins:}") String allowedOriginsProp) { - // This bean tells Spring Security how to load a user from the database. - @Bean - public UserDetailsService userDetailsService() { - return username -> { - com.coffeecodesyndicate.api.models.User user = userRepository.findUserByUsername(username) - .orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + username)); - - String role = "Role_Viewer"; //create initial role - if (user.getIsRegistered()) { - role = "Role_Registered"; - } - if (user.getIsAdmin()) { - role = "Role_Admin"; - } - - String finalRole = role; //set what the determined role really is - //and now return everything we need for the user - return new UserDetails() { - @Override public String getUsername() { return user.getUsername(); } - @Override public String getPassword() { return user.getPassword(); } - @Override - public java.util.Collection getAuthorities() { - return Collections.singletonList(new org.springframework.security.core.authority.SimpleGrantedAuthority(finalRole)); - } - @Override public boolean isAccountNonExpired() { return true; } - @Override public boolean isAccountNonLocked() { return true; } - @Override public boolean isCredentialsNonExpired() { return true; } - @Override public boolean isEnabled() { return true; } - }; - }; + CorsConfiguration config = new CorsConfiguration(); + if (allowedOriginsProp != null && !allowedOriginsProp.isBlank()) { + List origins = Arrays.stream(allowedOriginsProp.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + config.setAllowedOrigins(origins); + } else { + config.setAllowedOrigins(List.of("*")); // dev fallback + } + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; } } diff --git a/src/main/java/com/coffeecodesyndicate/api/controllers/AdminController.java b/src/main/java/com/coffeecodesyndicate/api/controllers/AdminController.java index 378018f..b845cbf 100644 --- a/src/main/java/com/coffeecodesyndicate/api/controllers/AdminController.java +++ b/src/main/java/com/coffeecodesyndicate/api/controllers/AdminController.java @@ -15,7 +15,7 @@ @RestController @RequestMapping("/admin/pets") -@PreAuthorize("hasRole('ADMIN')") +@PreAuthorize("hasRole('isAdmin')") public class AdminController { private final PetRepository PetRepository; @@ -82,8 +82,7 @@ public void deleteUser(@PathVariable Integer id) { UserRepository.deleteById(id); } - - + // create @PostMapping("/applications") public Application createApplication(@RequestBody Application application) { return ApplicationRepository.save(application); } diff --git a/src/main/java/com/coffeecodesyndicate/api/controllers/ApplicationController.java b/src/main/java/com/coffeecodesyndicate/api/controllers/ApplicationController.java index 205008f..dbe61b1 100644 --- a/src/main/java/com/coffeecodesyndicate/api/controllers/ApplicationController.java +++ b/src/main/java/com/coffeecodesyndicate/api/controllers/ApplicationController.java @@ -2,6 +2,7 @@ import com.coffeecodesyndicate.api.models.Application; import com.coffeecodesyndicate.api.models.ApplicationStatus; +import com.coffeecodesyndicate.api.models.User; import com.coffeecodesyndicate.api.services.ApplicationService; import com.coffeecodesyndicate.api.services.UserService; import org.springframework.http.HttpStatus; @@ -20,40 +21,37 @@ public ApplicationController(ApplicationService apps, UserService users) { this.users = users; } - // List all applications @GetMapping("/apps") public List all() { return apps.findAll(); } - // Get one application @GetMapping("/apps/{id}") public Application one(@PathVariable Integer id) { return apps.findById(id); } - // Create application (body already has user set or null) @PostMapping("/apps") @ResponseStatus(HttpStatus.CREATED) public Application create(@RequestBody Application a) { return apps.create(a); } - // Create application for a specific user + // NOTE: User IDs are Long (UserRepository) @PostMapping("/apps/user/{userId}") @ResponseStatus(HttpStatus.CREATED) - public Application createForUser(@PathVariable Integer userId, @RequestBody Application a) { - a.setUser(users.findById(userId)); + public Application createForUser(@PathVariable Long userId, @RequestBody Application a) { + User u = users.findById(userId) + .orElseThrow(() -> new RuntimeException("User not found: " + userId)); + a.setUser(u); return apps.create(a); } - //update application's status, not the whole Application object @PutMapping("/apps/{id}/status") public Application updateStatus(@PathVariable Integer id, @RequestParam ApplicationStatus status) { return apps.update(id, status); } - // Delete application @DeleteMapping("/apps/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete(@PathVariable Integer id) { diff --git a/src/main/java/com/coffeecodesyndicate/api/controllers/AuthController.java b/src/main/java/com/coffeecodesyndicate/api/controllers/AuthController.java new file mode 100644 index 0000000..95b8b84 --- /dev/null +++ b/src/main/java/com/coffeecodesyndicate/api/controllers/AuthController.java @@ -0,0 +1,74 @@ +package com.coffeecodesyndicate.api.controllers; + +import com.coffeecodesyndicate.api.dto.AuthDTOS; +import com.coffeecodesyndicate.api.models.User; +import com.coffeecodesyndicate.api.services.UserService; +import com.coffeecodesyndicate.api.config.JService; +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.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private final AuthenticationManager authenticationManager; + private final UserService userService; + private final JService jwt; + + public AuthController(AuthenticationManager authenticationManager, + UserService userService, + JService jwt) { + this.authenticationManager = authenticationManager; + this.userService = userService; + this.jwt = jwt; + } + + /** + * Register a new user. + * Expects AuthDTOS.RegisterRequest { username, email, password }. + * Returns AuthDTOS.AuthResponse { token, message }. + */ + @PostMapping("/register") + public ResponseEntity register(@RequestBody AuthDTOS.RegisterRequest req) { + // build the user model (no raw password inside entity) + User u = new User(); + u.setUsername(req.username()); + u.setEmail(req.email()); + + // service will hash the password and save + User created = userService.registerUser(u, req.password()); + + // issue a JWT for immediate login + String token = jwt.generateToken(created.getEmail()); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(new AuthDTOS.AuthResponse(token, "Registered")); + } + + /** + * Login with email + password. + * Expects AuthDTOS.LoginRequest { email, password }. + * Returns AuthDTOS.AuthResponse { token, message }. + */ + @PostMapping("/login") + public ResponseEntity login(@RequestBody AuthDTOS.LoginRequest req) { + try { + Authentication auth = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(req.email(), req.password()) + ); + // principal username is the email per SecurityConfig UserDetailsService + String email = auth.getName(); + String token = jwt.generateToken(email); + return ResponseEntity.ok(new AuthDTOS.AuthResponse(token, "OK")); + } catch (BadCredentialsException ex) { + // consistent 401 when bad creds + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new AuthDTOS.AuthResponse(null, "Invalid credentials")); + } + } +} diff --git a/src/main/java/com/coffeecodesyndicate/api/controllers/UserController.java b/src/main/java/com/coffeecodesyndicate/api/controllers/UserController.java index 9b6c867..78d61be 100644 --- a/src/main/java/com/coffeecodesyndicate/api/controllers/UserController.java +++ b/src/main/java/com/coffeecodesyndicate/api/controllers/UserController.java @@ -1,49 +1,50 @@ package com.coffeecodesyndicate.api.controllers; +import com.coffeecodesyndicate.api.dto.AuthDTOS; import com.coffeecodesyndicate.api.models.Pet; import com.coffeecodesyndicate.api.models.User; import com.coffeecodesyndicate.api.repositories.PetRepository; import com.coffeecodesyndicate.api.services.UserService; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; -import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; - import java.util.List; +import java.util.List; @RestController @RequestMapping("/unregistered") public class UserController { - @Autowired - private UserService userService; + private final UserService userService; + private final PetRepository petRepository; - //Non-registered users can access pets and see specific pets (by id) - @Autowired - private PetRepository petRepository; + public UserController(UserService userService, PetRepository petRepository) { + this.userService = userService; + this.petRepository = petRepository; + } - //get all pets + // public pet endpoints @GetMapping("/pets") public List getAllPets() { return petRepository.findAll(); - }; + } - //get pet by id @GetMapping("/pets/{id}") public Pet getPetById(@PathVariable Integer id) { return petRepository.findById(id).orElse(null); - }; + } @GetMapping("/users") -// @PreAuthorize("hasRole('isAdmin')") //only admins can get all user info, uncomment this when login route is done public List getAllUsers() { return userService.findAll(); } + // Register using DTO -> hashes into passwordHash @PostMapping("/register") @ResponseStatus(HttpStatus.CREATED) - public User registerUser(@RequestBody User user) { - return userService.registerUser(user); + public User registerUser(@RequestBody AuthDTOS.RegisterRequest req) { + User u = new User(); + u.setUsername(req.username()); + u.setEmail(req.email()); + return userService.registerUser(u, req.password()); } - } diff --git a/src/main/java/com/coffeecodesyndicate/api/dto/AuthDTOS.java b/src/main/java/com/coffeecodesyndicate/api/dto/AuthDTOS.java new file mode 100644 index 0000000..1897bad --- /dev/null +++ b/src/main/java/com/coffeecodesyndicate/api/dto/AuthDTOS.java @@ -0,0 +1,27 @@ +package com.coffeecodesyndicate.api.dto; + +/** + * Collection of authentication-related DTOs. + * You can also split these into separate files if you prefer. + */ +public class AuthDTOS { + + // Request body for registration + public static record RegisterRequest( + String username, + String email, + String password + ) {} + + // Request body for login + public static record LoginRequest( + String email, + String password + ) {} + + // Response body after login or register (JWT token and optional message) + public static record AuthResponse( + String token, + String message + ) {} +} diff --git a/src/main/java/com/coffeecodesyndicate/api/models/Pet.java b/src/main/java/com/coffeecodesyndicate/api/models/Pet.java index 68f5f9d..8c34868 100644 --- a/src/main/java/com/coffeecodesyndicate/api/models/Pet.java +++ b/src/main/java/com/coffeecodesyndicate/api/models/Pet.java @@ -32,8 +32,11 @@ public class Pet { public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } + public User getOwner() { return owner; } + public void setOwner(User owner) { this.owner = owner; } + public String getDescription() { return description; } - public void setDescription(String description) { this.description = Pet.this.description; } + public void setDescription(String description) { this.description = description; } public Boolean getIsAdopted() { return isAdopted; } public void setIsAdopted(Boolean isAdopted) { this.isAdopted = isAdopted; } diff --git a/src/main/java/com/coffeecodesyndicate/api/models/User.java b/src/main/java/com/coffeecodesyndicate/api/models/User.java index f950a0f..5908dda 100644 --- a/src/main/java/com/coffeecodesyndicate/api/models/User.java +++ b/src/main/java/com/coffeecodesyndicate/api/models/User.java @@ -1,56 +1,113 @@ package com.coffeecodesyndicate.api.models; import jakarta.persistence.*; - +import java.time.Instant; import java.util.Set; + @Entity -@Table(name = "Users") +@Table( + name = "Users", + uniqueConstraints = { + @UniqueConstraint(name = "uk_users_username", columnNames = "username"), + @UniqueConstraint(name = "uk_users_email", columnNames = "email") + } +) public class User { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Integer id; + private Long id; - @Column(nullable = false, unique = true) + @Column(nullable = false, unique = true, length = 50) private String username; - @Column(nullable = false) - private String password; - - @Column(nullable = false) + @Column(nullable = false, unique = true, length = 100) private String email; - //isRegistered means they can adopt - //if isRegistered is false, can view only - private Boolean isRegistered; + @Column(nullable = false, length = 255) + private String passwordHash; - //if true, user has admin privileges - private Boolean isAdmin; + private Boolean isRegistered = false; + private Boolean isAdmin = false; + private Boolean enabled = true; + private Boolean locked = false; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @Column(nullable = false) + private Instant updatedAt; - //a user can have many applications @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private Set applications; - //a user can have many pets - //no cascade or orphanRemoval, so if user is deleted the associated pets aren't deleted either and can default back to being adoptable @OneToMany(mappedBy = "owner") private Set pets; - public Integer getId() { return id; } - public void setId(Integer id) { this.id = id; } + /* ==== NEW: transient raw password field ==== */ + @Transient + private String password; // not stored in DB + + public String getPassword() { + return password; + } + public void setPassword(String password) { + this.password = password; + } + + /* ==== Lifecycle hooks ==== */ + @PrePersist + protected void onCreate() { + Instant now = Instant.now(); + this.createdAt = now; + this.updatedAt = now; + + hashPasswordIfNeeded(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = Instant.now(); + hashPasswordIfNeeded(); + } + + private void hashPasswordIfNeeded() { } + /* ========= Getters and Setters ========= */ + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } - public String getPassword() { return password; } - public void setPassword(String password) { this.password = password; } - public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } - public boolean getIsRegistered() { return isRegistered; } + public String getPasswordHash() { return passwordHash; } + public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; } + + public Boolean getIsRegistered() { return isRegistered; } public void setIsRegistered(Boolean isRegistered) { this.isRegistered = isRegistered; } public Boolean getIsAdmin() { return isAdmin; } public void setIsAdmin(Boolean isAdmin) { this.isAdmin = isAdmin; } + + public Boolean getEnabled() { return enabled; } + public void setEnabled(Boolean enabled) { this.enabled = enabled; } + + public Boolean getLocked() { return locked; } + public void setLocked(Boolean locked) { this.locked = locked; } + + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + + public Instant getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } + + public Set getApplications() { return applications; } + public void setApplications(Set applications) { this.applications = applications; } + + public Set getPets() { return pets; } + public void setPets(Set pets) { this.pets = pets; } } diff --git a/src/main/java/com/coffeecodesyndicate/api/repositories/UserRepository.java b/src/main/java/com/coffeecodesyndicate/api/repositories/UserRepository.java index 20ecb36..0bd25bd 100644 --- a/src/main/java/com/coffeecodesyndicate/api/repositories/UserRepository.java +++ b/src/main/java/com/coffeecodesyndicate/api/repositories/UserRepository.java @@ -2,9 +2,22 @@ import com.coffeecodesyndicate.api.models.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; import java.util.Optional; -public interface UserRepository extends JpaRepository { - Optional findUserByUsername(String username); +@Repository +public interface UserRepository extends JpaRepository { + + Optional findByEmailIgnoreCase(String email); + boolean existsByEmailIgnoreCase(String email); + Optional findByUsernameIgnoreCase(String username); + + // --- Compatibility for teammate's controller (Integer IDs) --- + default Optional findById(Integer id) { + return findById(id == null ? null : id.longValue()); + } + default void deleteById(Integer id) { + if (id != null) deleteById(id.longValue()); + } } diff --git a/src/main/java/com/coffeecodesyndicate/api/services/PetService.java b/src/main/java/com/coffeecodesyndicate/api/services/PetService.java index fefedbc..348cd17 100644 --- a/src/main/java/com/coffeecodesyndicate/api/services/PetService.java +++ b/src/main/java/com/coffeecodesyndicate/api/services/PetService.java @@ -1,6 +1,7 @@ package com.coffeecodesyndicate.api.services; import com.coffeecodesyndicate.api.models.Pet; +import com.coffeecodesyndicate.api.models.User; import com.coffeecodesyndicate.api.repositories.PetRepository; import org.springframework.stereotype.Service; @@ -14,7 +15,7 @@ public PetService(PetRepository repo) { this.repo = repo; } - public List findAll() { return repo.findAll(); } + public List findAllByOwner(User owner) { return repo.findAll(); } public Pet findById(Integer id) { return repo.findById(id).orElseThrow(() -> new RuntimeException("Pet not found")); diff --git a/src/main/java/com/coffeecodesyndicate/api/services/UserService.java b/src/main/java/com/coffeecodesyndicate/api/services/UserService.java index 31ee2e4..1a1c173 100644 --- a/src/main/java/com/coffeecodesyndicate/api/services/UserService.java +++ b/src/main/java/com/coffeecodesyndicate/api/services/UserService.java @@ -10,61 +10,57 @@ @Service public class UserService { - private final UserRepository repo; + + private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; - public UserService(UserRepository repo, PasswordEncoder passwordEncoder) { - this.repo = repo; + public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; } public List findAll() { - return repo.findAll(); + return userRepository.findAll(); } - public User findById(Integer id) { - return repo.findById(id).orElseThrow(() -> new RuntimeException("User not found")); + public Optional findById(Long id) { + return userRepository.findById(id); } - public User create(User u) { - u.setId(null); // let JPA generate - return repo.save(u); + public Optional findByEmail(String email) { + return userRepository.findByEmailIgnoreCase(email); } - public User update(Integer id, User u) { - // ensure it exists (optional) - if (!repo.existsById(id)) throw new RuntimeException("User not found"); - u.setId(id); - return repo.save(u); + public boolean emailExists(String email) { + return userRepository.existsByEmailIgnoreCase(email); } - public Optional findUserByUsername(String username) { - return repo.findUserByUsername(username); + public User save(User user) { + return userRepository.save(user); } - public User registerUser(User user) { - if (repo.findUserByUsername(user.getUsername()).isPresent()) { - throw new RuntimeException("Username already exists"); + /** Register a new user (hash raw password into passwordHash). */ + public User registerUser(User user, String rawPassword) { + if (userRepository.existsByEmailIgnoreCase(user.getEmail())) { + throw new IllegalArgumentException("Email already in use"); } - user.setPassword(passwordEncoder.encode(user.getPassword())); - user.setIsRegistered(true); //user is going to be isRegistered, but not isAdmin - user.setIsAdmin(false); - return repo.save(user); - } + // Prefer rawPassword param, fallback to transient user.getPassword() + String toHash = (rawPassword != null && !rawPassword.isBlank()) + ? rawPassword + : user.getPassword(); - public User registerAdminUser(User user) { - if (repo.findUserByUsername(user.getUsername()).isPresent()) { - throw new RuntimeException("Username already exists"); + if (toHash == null || toHash.isBlank()) { + throw new IllegalArgumentException("Password is required"); } - user.setPassword(passwordEncoder.encode(user.getPassword())); - user.setIsRegistered(true); //user is going to be isRegistered and isAdmin - user.setIsAdmin(true); - return repo.save(user); - } + user.setPasswordHash(passwordEncoder.encode(toHash)); + user.setPassword(null); // clear transient field + + if (user.getEnabled() == null) user.setEnabled(true); + if (user.getLocked() == null) user.setLocked(false); + if (user.getIsAdmin() == null) user.setIsAdmin(false); - public void delete(Integer id) { - repo.deleteById(id); + return userRepository.save(user); } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 10e0a88..047911f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,21 @@ +# Application name spring.application.name=MV-API + +security.jwt.secret=mysupersecretkeymysupersecretkey123! + +# Access token expiry in minutes +security.jwt.expMinutes=60 + +# Refresh token expiry in days +security.jwt.refreshDays=14 + +# ====================== +# (Optional) CORS configuration for frontend origins +# ====================== +app.cors.allowed-origins=http://localhost:5173,http://localhost:3000 +spring.security.oauth2.client.registration.auth0.client-id=${AUTH0_CLIENT_ID} +spring.security.oauth2.client.registration.auth0.client-secret=${AUTH0_CLIENT_SECRET} +spring.security.oauth2.client.registration.auth0.scope=openid, profile, email +spring.security.oauth2.client.registration.auth0.redirect-uri=${RENDER_REDIRECT_URI} +spring.security.oauth2.client.provider.auth0.issuer-uri=${RENDER_ISSUER_URI} +