diff --git a/README b/README new file mode 100644 index 0000000..cf998ee --- /dev/null +++ b/README @@ -0,0 +1,41 @@ + Register a user (replace with your details): + curl -X POST http://localhost:8080/api/auth/register \ + -H "Content-Type: application/json" \ + -d '{"username":"testuser","password":"testpass123"}' + Response: {"message":"User registered successfully"} + + Login to get JWT token (use same credentials): + curl -X POST http://localhost:8080/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"testuser","password":"testpass123"}' + Response: {"token":"","username":"testuser"} + + Store token (bash example): + TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"testuser","password":"testpass123"}' | jq -r '.token') + + Test protected endpoint (list jobs; requires token): + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/jobs + Response: JSON array of jobs (empty if none exist). + + Create a company (protected): + curl -X POST http://localhost:8080/api/companies \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"Test Corp","website":"https://test.com","notes":"Test"}' + + Create a job (needs company ID from above): + curl -X POST http://localhost:8080/api/jobs \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"role":"Software Engineer","location":"Remote","company":{"id":""},"status":"APPLIED"}' + + List jobs by company: + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/companies//jobs + + Export jobs as CSV: + curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/jobs/export -o jobs.csv + + Other endpoints follow similar patterns (PUT/PATCH/DELETE for updates/deletes). Change SECRET_KEY in JwtUtil.java to a secure value (env var recommended) and restart server. If 403, ensure token is + valid/not expired. Let me know results! diff --git a/build.gradle b/build.gradle index 5281d1f..d918bcf 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,10 @@ // Spring Boot starters implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'javax.xml.bind:jaxb-api:2.3.1' + runtimeOnly 'org.glassfish.jaxb:jaxb-runtime:2.3.2' // Database driver runtimeOnly 'org.postgresql:postgresql:42.7.3' @@ -37,10 +41,12 @@ annotationProcessor 'org.projectlombok:lombok' // Exporting to CSV implementation 'com.opencsv:opencsv:5.9' + implementation 'org.springframework.boot:spring-boot-starter-mail' // Testing testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.testcontainers:junit-jupiter' testImplementation 'org.testcontainers:postgresql' } diff --git a/src/main/java/com/kpaudel/config/MailConfig.java b/src/main/java/com/kpaudel/config/MailConfig.java new file mode 100644 index 0000000..52ff65c --- /dev/null +++ b/src/main/java/com/kpaudel/config/MailConfig.java @@ -0,0 +1,44 @@ +package com.kpaudel.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +import java.util.Properties; + +@Configuration +public class MailConfig { + + @Value("${spring.mail.host}") + private String host; + + @Value("${spring.mail.port}") + private int port; + + @Value("${spring.mail.username}") + private String username; + + @Value("${spring.mail.password}") + private String password; + + @Bean + public JavaMailSender javaMailSender() { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(host); + mailSender.setPort(port); + + if (!username.isEmpty() && !password.isEmpty()) { + mailSender.setUsername(username); + mailSender.setPassword(password); + } + + Properties props = mailSender.getJavaMailProperties(); + props.put("mail.smtp.auth", "true"); + props.put("mail.smtp.starttls.enable", "true"); + props.put("mail.debug", "true"); + + return mailSender; + } +} \ No newline at end of file diff --git a/src/main/java/com/kpaudel/config/SecurityConfig.java b/src/main/java/com/kpaudel/config/SecurityConfig.java new file mode 100644 index 0000000..5d74d60 --- /dev/null +++ b/src/main/java/com/kpaudel/config/SecurityConfig.java @@ -0,0 +1,50 @@ +package com.kpaudel.config; + +import com.kpaudel.security.JwtAuthenticationFilter; +import com.kpaudel.security.JwtUtil; +import com.kpaudel.security.UserDetailsServiceImpl; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { + http.csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authz -> authz + .requestMatchers("/api/auth/**", "/").permitAll() + .requestMatchers("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.ico", "/index.html").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) { + return new JwtAuthenticationFilter(jwtUtil, userDetailsService); + } +} \ No newline at end of file diff --git a/src/main/java/com/kpaudel/controller/ApiController.java b/src/main/java/com/kpaudel/controller/ApiController.java index 1a2529e..c7d5fae 100644 --- a/src/main/java/com/kpaudel/controller/ApiController.java +++ b/src/main/java/com/kpaudel/controller/ApiController.java @@ -3,8 +3,10 @@ import com.kpaudel.model.ApplicationStatus; import com.kpaudel.model.Company; import com.kpaudel.model.JobApplication; +import com.kpaudel.model.User; import com.kpaudel.repository.CompanyRepository; import com.kpaudel.repository.JobApplicationRepository; +import com.kpaudel.repository.UserRepository; import com.kpaudel.service.JobService; import com.opencsv.CSVWriter; import org.springframework.http.HttpHeaders; @@ -12,6 +14,8 @@ import org.springframework.http.MediaType; import org.springframework.http.ContentDisposition; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.io.ByteArrayOutputStream; @@ -19,6 +23,7 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; +import java.util.UUID; @RestController @RequestMapping("/api") @@ -27,52 +32,78 @@ private final JobService service; private final CompanyRepository companyRepo; private final JobApplicationRepository jobRepo; + private final UserRepository userRepo; - public ApiController(JobService service, CompanyRepository companyRepo, JobApplicationRepository jobRepo) { + public ApiController(JobService service, CompanyRepository companyRepo, JobApplicationRepository jobRepo, UserRepository userRepo) { this.service = service; this.companyRepo = companyRepo; this.jobRepo = jobRepo; + this.userRepo = userRepo; + } + + private User getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String username = authentication.getName(); + return userRepo.findByUsername(username).orElseThrow(() -> new RuntimeException("User not found")); + } + + private UUID getCurrentUserId() { + return getCurrentUser().getId(); } @GetMapping("/jobs") public List listJobs(@RequestParam Optional status) { - return status.map(jobRepo::findByStatus).orElse(jobRepo.findAll()); + UUID userId = getCurrentUserId(); + if (status.isPresent()) { + return service.listByUserAndStatus(userId, status.get()); + } + return service.listAllByUser(userId); } @GetMapping("/jobs/{id}") public ResponseEntity getJob(@PathVariable UUID id) { - return this.jobRepo.findById(id).map(ResponseEntity::ok) - .orElse(ResponseEntity.notFound().build()); + UUID userId = getCurrentUserId(); + JobApplication job = this.jobRepo.findById(id).orElse(null); + if (job == null || !job.getUser().getId().equals(userId)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(job); } @PostMapping("/jobs") public JobApplication createJob(@RequestBody JobApplication job) { - return this.service.create(job.getCompany().getId(), job); + UUID userId = getCurrentUserId(); + return this.service.create(job, userId); } @PutMapping("/jobs/{id}") public ResponseEntity updateJob(@PathVariable UUID id, @RequestBody JobApplication updated) { - return jobRepo.findById(id) - .map(existing -> { - updated.setId(id); - return ResponseEntity.ok(jobRepo.save(updated)); - }) - .orElse(ResponseEntity.notFound().build()); + UUID userId = getCurrentUserId(); + JobApplication existing = jobRepo.findById(id).orElse(null); + if (existing == null || !existing.getUser().getId().equals(userId)) { + return ResponseEntity.notFound().build(); + } + updated.setId(id); + updated.setUser(existing.getUser()); // Preserve user + return ResponseEntity.ok(jobRepo.save(updated)); } @PatchMapping("/jobs/{id}/status") public JobApplication updateStatus(@PathVariable UUID id, @RequestBody Map body) { + UUID userId = getCurrentUserId(); ApplicationStatus status = ApplicationStatus.valueOf(body.get("status")); - return service.updateStatus(id, status); + return service.updateStatus(id, status, userId); } @DeleteMapping("/jobs/{id}") public ResponseEntity deleteJob(@PathVariable UUID id) { - if (jobRepo.existsById(id)) { - jobRepo.deleteById(id); - return ResponseEntity.noContent().build(); + UUID userId = getCurrentUserId(); + JobApplication job = jobRepo.findById(id).orElse(null); + if (job == null || !job.getUser().getId().equals(userId)) { + return ResponseEntity.notFound().build(); } - return ResponseEntity.notFound().build(); + jobRepo.deleteById(id); + return ResponseEntity.noContent().build(); } @GetMapping("/companies") @@ -89,7 +120,8 @@ @GetMapping("/companies/{id}/jobs") public List listJobsByCompany(@PathVariable UUID id) { - return jobRepo.findByCompanyId(id); + UUID userId = getCurrentUserId(); + return service.listByCompanyAndUser(id, userId); } @PostMapping("/companies") @@ -119,7 +151,8 @@ @GetMapping("/jobs/export") public ResponseEntity exportJobs() throws IOException { - List jobs = this.jobRepo.findAll(); + UUID userId = getCurrentUserId(); + List jobs = service.listAllByUser(userId); ByteArrayOutputStream out = new ByteArrayOutputStream(); CSVWriter writer = new CSVWriter(new java.io.OutputStreamWriter(out)); //Add headers @@ -150,6 +183,29 @@ return new ResponseEntity<>(data, headers, HttpStatus.OK); } + + // Add to service if needed + public List listByUserAndStatus(UUID userId, ApplicationStatus status) { + return this.jobRepo.findByUserIdAndStatus(userId, status); + } + + public List listByCompanyAndUser(UUID userId, UUID companyId) { + return this.jobRepo.findByUserIdAndCompanyId(userId, companyId); + } + + @GetMapping("/user/profile") + public ResponseEntity> getProfile() { + User user = getCurrentUser(); + Map profile = new HashMap<>(); + profile.put("id", user.getId()); + profile.put("username", user.getUsername()); + profile.put("email", user.getEmail()); + profile.put("firstName", user.getFirstName()); + profile.put("lastName", user.getLastName()); + profile.put("status", user.getStatus().name()); + return ResponseEntity.ok(profile); + } + @GetMapping("/companies/export") public ResponseEntity exportCompanies() throws IOException { List companies = this.companyRepo.findAll(); diff --git a/src/main/java/com/kpaudel/controller/AuthController.java b/src/main/java/com/kpaudel/controller/AuthController.java new file mode 100644 index 0000000..5026cda --- /dev/null +++ b/src/main/java/com/kpaudel/controller/AuthController.java @@ -0,0 +1,113 @@ +package com.kpaudel.controller; + +import com.kpaudel.model.User; +import com.kpaudel.repository.UserRepository; +import com.kpaudel.security.JwtUtil; +import com.kpaudel.service.EmailService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +@RestController +@RequestMapping("/api/auth") +@CrossOrigin(origins = {"http://localhost:3000"}) +public class AuthController { + + @Autowired + private AuthenticationManager authenticationManager; + + @Autowired + private UserRepository userRepository; + + @Autowired + private JwtUtil jwtUtil; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private EmailService emailService; + + @PostMapping("/register") + public ResponseEntity register(@RequestBody Map request) { + String username = request.get("username"); + String email = request.get("email"); + String firstName=request.get("firstName"); + String lastName=request.get("lastName"); + String password = request.get("password"); + + if (userRepository.findByUsername(username).isPresent()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Username already exists")); + } + + if (userRepository.findByEmail(email).isPresent()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Email already exists")); + } + + User user = new User(); + user.setUsername(username); + user.setEmail(email); + user.setFirstName(firstName); + user.setLastName(lastName); + user.setPassword(passwordEncoder.encode(password)); + String confirmationToken = UUID.randomUUID().toString(); + user.setConfirmationToken(confirmationToken); + userRepository.save(user); + + emailService.sendConfirmationEmail(email, confirmationToken); + + return ResponseEntity.ok(Map.of("message", "User registered successfully. Please check your email for confirmation.")); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody Map request) { + String username = request.get("username"); + String password = request.get("password"); + + try { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(username, password) + ); + + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + String token = jwtUtil.generateToken(userDetails); + + return ResponseEntity.ok(Map.of("token", token, "username", username)); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Invalid credentials")); + } + } + + @GetMapping("/confirm") + public ResponseEntity confirm(@RequestParam String token) { + Optional optionalUser = userRepository.findByConfirmationToken(token); + if (optionalUser.isPresent()) { + User user = optionalUser.get(); + if (user.getStatus() == User.UserStatus.PENDING) { + user.setStatus(User.UserStatus.CONFIRMED); + user.setConfirmationToken(null); + userRepository.save(user); + emailService.sendWelcomeEmail(user.getEmail()); + return ResponseEntity.ok(Map.of("message", "Email confirmed successfully. You can now log in.")); + } else { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Invalid or already confirmed token")); + } + } + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Invalid token")); + } +} \ No newline at end of file diff --git a/src/main/java/com/kpaudel/model/JobApplication.java b/src/main/java/com/kpaudel/model/JobApplication.java index 2b3ebf7..61f97ed 100644 --- a/src/main/java/com/kpaudel/model/JobApplication.java +++ b/src/main/java/com/kpaudel/model/JobApplication.java @@ -38,6 +38,10 @@ private OffsetDateTime lastUpdated; + @ManyToOne(optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + @PrePersist @PreUpdate public void updateTimeStamp(){ diff --git a/src/main/java/com/kpaudel/model/User.java b/src/main/java/com/kpaudel/model/User.java new file mode 100644 index 0000000..03e5f3c --- /dev/null +++ b/src/main/java/com/kpaudel/model/User.java @@ -0,0 +1,45 @@ +package com.kpaudel.model; + +import jakarta.persistence.*; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "users") +public class User { + + @Column + private String confirmationToken; + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(unique = true, nullable = false) + private String username; + + @Column(unique = true, nullable = false) + private String email; + + private String firstName; + private String lastName; + + @Column(nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private UserStatus status = UserStatus.PENDING; + + + public enum UserStatus { + PENDING, CONFIRMED + } +} \ No newline at end of file diff --git a/src/main/java/com/kpaudel/repository/JobApplicationRepository.java b/src/main/java/com/kpaudel/repository/JobApplicationRepository.java index 1fcc614..a1daba5 100644 --- a/src/main/java/com/kpaudel/repository/JobApplicationRepository.java +++ b/src/main/java/com/kpaudel/repository/JobApplicationRepository.java @@ -3,11 +3,23 @@ import com.kpaudel.model.ApplicationStatus; import com.kpaudel.model.JobApplication; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.UUID; public interface JobApplicationRepository extends JpaRepository { List findByStatus(ApplicationStatus status); + List findByCompanyId(UUID companyId); + + @Query("SELECT j FROM JobApplication j WHERE j.user.id = :userId") + List findByUserId(@Param("userId") UUID userId); + + @Query("SELECT j FROM JobApplication j WHERE j.user.id = :userId AND j.company.id = :companyId") + List findByUserIdAndCompanyId(@Param("userId") UUID userId, @Param("companyId") UUID companyId); + + @Query("SELECT j FROM JobApplication j WHERE j.user.id = :userId AND j.status = :status") + List findByUserIdAndStatus(@Param("userId") UUID userId, @Param("status") ApplicationStatus status); } diff --git a/src/main/java/com/kpaudel/repository/UserRepository.java b/src/main/java/com/kpaudel/repository/UserRepository.java new file mode 100644 index 0000000..c7bf3ca --- /dev/null +++ b/src/main/java/com/kpaudel/repository/UserRepository.java @@ -0,0 +1,18 @@ +package com.kpaudel.repository; + +import com.kpaudel.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + Optional findByEmail(String email); + List findByStatus(User.UserStatus status); + + Optional findByConfirmationToken(String token); +} \ No newline at end of file diff --git a/src/main/java/com/kpaudel/security/JwtAuthenticationFilter.java b/src/main/java/com/kpaudel/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..f76f262 --- /dev/null +++ b/src/main/java/com/kpaudel/security/JwtAuthenticationFilter.java @@ -0,0 +1,53 @@ +package com.kpaudel.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final UserDetailsServiceImpl userDetailsService; + + public JwtAuthenticationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) { + this.jwtUtil = jwtUtil; + this.userDetailsService = userDetailsService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + String token = null; + String username = null; + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + token = authHeader.substring(7); + username = jwtUtil.extractUsername(token); + } + + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + if (jwtUtil.validateToken(token, userDetails)) { + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/com/kpaudel/security/JwtUtil.java b/src/main/java/com/kpaudel/security/JwtUtil.java new file mode 100644 index 0000000..0f805ff --- /dev/null +++ b/src/main/java/com/kpaudel/security/JwtUtil.java @@ -0,0 +1,74 @@ +package com.kpaudel.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +@Component +public class JwtUtil { + + private String SECRET_KEY_HEX = "404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970337336763979244226452948404D635166"; // Change this to a secure 64-char hex key (32 bytes) + + private byte[] signingKey = decodeHex(SECRET_KEY_HEX); + + private byte[] decodeHex(String hex) { + int len = hex.length(); + byte[] data = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4) + + Character.digit(hex.charAt(i+1), 16)); + } + return data; + } + + 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() + .setSigningKey(signingKey) + .parseClaimsJws(token) + .getBody(); + } + + 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() + .setClaims(claims) + .setSubject(subject) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 10)) + .signWith(SignatureAlgorithm.HS256, signingKey) + .compact(); + } + + public Boolean validateToken(String token, UserDetails userDetails) { + final String username = extractUsername(token); + return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); + } +} \ No newline at end of file diff --git a/src/main/java/com/kpaudel/security/UserDetailsServiceImpl.java b/src/main/java/com/kpaudel/security/UserDetailsServiceImpl.java new file mode 100644 index 0000000..c4f2c72 --- /dev/null +++ b/src/main/java/com/kpaudel/security/UserDetailsServiceImpl.java @@ -0,0 +1,33 @@ +package com.kpaudel.security; + +import com.kpaudel.model.User; +import com.kpaudel.repository.UserRepository; +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 +public class UserDetailsServiceImpl implements UserDetailsService { + + private final UserRepository userRepository; + + public UserDetailsServiceImpl(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); + + return org.springframework.security.core.userdetails.User + .withUsername(user.getUsername()) + .password(user.getPassword()) + .authorities(new SimpleGrantedAuthority("ROLE_USER")) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/kpaudel/service/EmailService.java b/src/main/java/com/kpaudel/service/EmailService.java new file mode 100644 index 0000000..c905230 --- /dev/null +++ b/src/main/java/com/kpaudel/service/EmailService.java @@ -0,0 +1,28 @@ +package com.kpaudel.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +@Service +public class EmailService { + @Autowired + private JavaMailSender mailSender; + + public void sendConfirmationEmail(String to, String token) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(to); + message.setSubject("Confirm your registration"); + message.setText("Please confirm your registration by clicking this link: http://localhost:8080/api/auth/confirm?token=" + token); + mailSender.send(message); + } + + public void sendWelcomeEmail(String to) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(to); + message.setSubject("Welcome!"); + message.setText("Your account has been confirmed."); + mailSender.send(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/kpaudel/service/JobService.java b/src/main/java/com/kpaudel/service/JobService.java index 879b6fc..117a95f 100644 --- a/src/main/java/com/kpaudel/service/JobService.java +++ b/src/main/java/com/kpaudel/service/JobService.java @@ -3,8 +3,11 @@ import com.kpaudel.model.ApplicationStatus; import com.kpaudel.model.Company; import com.kpaudel.model.JobApplication; +import com.kpaudel.model.User; import com.kpaudel.repository.CompanyRepository; import com.kpaudel.repository.JobApplicationRepository; +import com.kpaudel.repository.UserRepository; +import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; import java.util.List; @@ -14,25 +17,40 @@ public class JobService { private final JobApplicationRepository jobRepo; private final CompanyRepository companyRepo; + private final UserRepository userRepo; - public JobService(JobApplicationRepository jobRepo, CompanyRepository companyRepo) { + public JobService(JobApplicationRepository jobRepo, CompanyRepository companyRepo, UserRepository userRepo) { this.jobRepo = jobRepo; this.companyRepo = companyRepo; + this.userRepo = userRepo; } - public JobApplication create(UUID companyId, JobApplication data) { - Company company = this.companyRepo.findById(companyId).orElseThrow(() -> new IllegalArgumentException("Company not found!")); + public JobApplication create(JobApplication data, UUID userId) { + User user = this.userRepo.findById(userId).orElseThrow(() -> new IllegalArgumentException("User not found!")); + data.setUser(user); + Company company = this.companyRepo.findById(data.getCompany().getId()).orElseThrow(() -> new IllegalArgumentException("Company not found!")); data.setCompany(company); return jobRepo.save(data); } - public JobApplication updateStatus(UUID id, ApplicationStatus status) { + public JobApplication updateStatus(UUID id, ApplicationStatus status, UUID userId) { JobApplication job = this.jobRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("Job not found")); + if (!job.getUser().getId().equals(userId)) { + throw new AccessDeniedException("You can only update your own jobs"); + } job.setStatus(status); return this.jobRepo.save(job); } - public List listAll() { - return this.jobRepo.findAll(); + public List listAllByUser(UUID userId) { + return this.jobRepo.findByUserId(userId); + } + + public List listByUserAndStatus(UUID userId, ApplicationStatus status) { + return this.jobRepo.findByUserIdAndStatus(userId, status); + } + + public List listByCompanyAndUser(UUID companyId, UUID userId) { + return this.jobRepo.findByUserIdAndCompanyId(userId, companyId); } } diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index a94b6f0..6969149 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -1,5 +1,5 @@ server.port=8088 -spring.datasource.url=jdbc:postgresql://192.168.1.253:5432/db_test +spring.datasource.url=jdbc:postgresql://192.168.1.253:5432/db_test_job_tracker spring.datasource.username=postgres spring.datasource.password=00977 -spring.jpa.hibernate.ddl-auto=update +spring.jpa.hibernate.ddl-auto=create diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3539f0f..6668d45 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -13,4 +13,13 @@ spring.flyway.enabled=false spring.flyway.locations=classpath:db/migration spring.flyway.schemas=public -spring.flyway.baseline-on-migrate=true \ No newline at end of file +spring.flyway.baseline-on-migrate=true + +# Mail Configuration +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=${MAIL_USERNAME:} +spring.mail.password=${MAIL_PASSWORD:} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true +spring.mail.properties.mail.debug=true \ No newline at end of file diff --git a/src/main/resources/db/migration/V3__create_users_table.sql b/src/main/resources/db/migration/V3__create_users_table.sql new file mode 100644 index 0000000..efca045 --- /dev/null +++ b/src/main/resources/db/migration/V3__create_users_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE users ( + id UUID PRIMARY KEY, + username VARCHAR(255) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + first_name VARCHAR(255), + last_name VARCHAR(255), + password VARCHAR(255) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + confirmation_token VARCHAR(255) +); + +ALTER TABLE IF EXISTS public.users OWNER to jobuser; \ No newline at end of file diff --git a/src/main/resources/db/migration/V4__add_user_to_job_applications.sql b/src/main/resources/db/migration/V4__add_user_to_job_applications.sql new file mode 100644 index 0000000..587a633 --- /dev/null +++ b/src/main/resources/db/migration/V4__add_user_to_job_applications.sql @@ -0,0 +1,10 @@ +-- Add user_id to job_applications only (companies remain shared across users) +ALTER TABLE job_applications ADD COLUMN user_id UUID NOT NULL DEFAULT (SELECT id FROM users ORDER BY id LIMIT 1); +-- Default to first user for existing data; adjust as needed + +-- Add foreign key +ALTER TABLE job_applications ADD CONSTRAINT fk_job_user +FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE; + +-- Update existing records to user's ID if needed (requires knowing which user) +-- For new multi-user setup, existing jobs will need manual assignment to a user \ No newline at end of file diff --git a/src/main/resources/db/migration/V5__add_email_and_status_to_users.sql b/src/main/resources/db/migration/V5__add_email_and_status_to_users.sql new file mode 100644 index 0000000..4358b99 --- /dev/null +++ b/src/main/resources/db/migration/V5__add_email_and_status_to_users.sql @@ -0,0 +1,8 @@ +-- Add new columns to users table +ALTER TABLE users ADD COLUMN email VARCHAR(255) UNIQUE; +ALTER TABLE users ADD COLUMN first_name VARCHAR(255); +ALTER TABLE users ADD COLUMN last_name VARCHAR(255); +ALTER TABLE users ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'PENDING'; +ALTER TABLE users ADD COLUMN confirmation_token VARCHAR(255); +-- Update existing users to CONFIRMED if needed +-- UPDATE users SET status = 'CONFIRMED' WHERE username = 'testuser'; -- Adjust as needed \ No newline at end of file