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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 4 additions & 12 deletions src/main/java/com/jobtracker/config/GptOAuthSecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
import org.springframework.security.web.SecurityFilterChain;

import javax.crypto.SecretKey;
Expand All @@ -44,20 +42,14 @@ public class GptOAuthSecurityConfig {

@Bean
@Order(1)
public SecurityFilterChain gptOAuthSecurityFilterChain(HttpSecurity http,
Converter<Jwt, ? extends AbstractAuthenticationToken> gptJwtAuthenticationConverter) throws Exception {
public SecurityFilterChain gptOAuthSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/oauth2/**", "/api/v1/gpt/**")
.csrf(csrf -> csrf.ignoringRequestMatchers("/oauth2/**", "/api/v1/gpt/**"))
.securityMatcher("/oauth2/**")
.csrf(csrf -> csrf.ignoringRequestMatchers("/oauth2/**"))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/oauth2/authorize", "/oauth2/token").permitAll()
.requestMatchers("/api/v1/gpt/**").authenticated()
.anyRequest().denyAll())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(gptJwtAuthenticationConverter)))
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
.accessDeniedHandler(new BearerTokenAccessDeniedHandler()));
.anyRequest().denyAll());

return http.build();
}
Expand Down
53 changes: 44 additions & 9 deletions src/main/java/com/jobtracker/config/JwtAuthenticationFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
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.core.userdetails.UsernameNotFoundException;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
Expand All @@ -24,10 +28,17 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtService jwtService;
private final UserDetailsService userDetailsService;
private final JwtDecoder gptOAuthJwtDecoder;
private final Converter<Jwt, ? extends AbstractAuthenticationToken> gptJwtAuthenticationConverter;

public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) {
public JwtAuthenticationFilter(JwtService jwtService,
UserDetailsService userDetailsService,
JwtDecoder gptOAuthJwtDecoder,
Converter<Jwt, ? extends AbstractAuthenticationToken> gptJwtAuthenticationConverter) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
this.gptOAuthJwtDecoder = gptOAuthJwtDecoder;
this.gptJwtAuthenticationConverter = gptJwtAuthenticationConverter;
}

@Override
Expand All @@ -40,29 +51,53 @@ protected void doFilterInternal(HttpServletRequest request,
return;
}

final String jwt = authHeader.substring(7);
final String token = authHeader.substring(7);
if (!tryUserJwt(token, request)) {
tryGptOAuthToken(token, request);
}

filterChain.doFilter(request, response);
}

private boolean tryUserJwt(String token, HttpServletRequest request) {
try {
final String userEmail = jwtService.extractUsername(jwt);
final String userEmail = jwtService.extractUsername(token);
if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = null;
try {
userDetails = userDetailsService.loadUserByUsername(userEmail);
} catch (UsernameNotFoundException e) {
log.debug("JWT authentication failed: user not found for email={}", userEmail);
return false;
}

if (userDetails != null && jwtService.isTokenValid(jwt, userDetails)) {
if (userDetails != null && jwtService.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails, null, jwtService.extractAuthorities(jwt));
userDetails, null, jwtService.extractAuthorities(token));
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
return true;
}
log.debug("User JWT authentication failed: token invalid for email={}", userEmail);
return false;
}
} catch (Exception e) {
// Log invalid JWT token at debug level and continue without authentication
log.debug("JWT authentication failed: {}", e.getMessage());
// Log invalid user JWT at debug level; fall through to GPT token attempt
log.debug("User JWT authentication failed: {}", e.getMessage());
}
return false;
}

filterChain.doFilter(request, response);
private void tryGptOAuthToken(String token, HttpServletRequest request) {
try {
Jwt jwt = gptOAuthJwtDecoder.decode(token);
AbstractAuthenticationToken authToken =
(AbstractAuthenticationToken) gptJwtAuthenticationConverter.convert(jwt);
if (authToken != null) {
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
} catch (Exception e) {
log.debug("GPT OAuth token authentication failed: {}", e.getMessage());
}
}
}
13 changes: 12 additions & 1 deletion src/main/java/com/jobtracker/config/OpenApiConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,18 @@ public GroupedOpenApi gptOpenApi() {
.group("gpt-actions")
.displayName("GPT Actions API")
.packagesToScan(CONTROLLER_PACKAGE)
.pathsToMatch("/api/v1/gpt/**", "/oauth2/authorize", "/oauth2/token")
.pathsToMatch(
"/oauth2/authorize",
"/oauth2/token",
"/api/v1/auth/me",
"/api/v1/applications",
"/api/v1/applications/{id}",
"/api/v1/applications/{id}/status",
"/api/v1/google-drive/status",
"/api/v1/google-drive/base-resumes",
"/api/v1/google-drive/base-resumes/{resumeId}/content",
"/api/v1/google-drive/applications/{applicationId}/generated-resumes/content"
)
.build();
}
}
12 changes: 11 additions & 1 deletion src/main/java/com/jobtracker/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,21 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
"/api/v1/auth/reset-password",
"/api/v1/auth/logout").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/google-drive/oauth/callback").permitAll()
.requestMatchers("/oauth2/authorize", "/oauth2/token").permitAll()
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
// Actuator is served on a dedicated management port (8081) that is never
// exposed to the host; security is enforced via Docker network isolation.
.requestMatchers("/actuator/**").permitAll()
// GPT OAuth tokens (ROLE_GPT_CLIENT) may access these specific endpoints.
// Method-level @PreAuthorize further enforces required scopes.
.requestMatchers(HttpMethod.GET, "/api/v1/auth/me").hasAnyRole("USER", "GPT_CLIENT")
.requestMatchers(HttpMethod.GET, "/api/v1/applications").hasAnyRole("USER", "GPT_CLIENT")
.requestMatchers(HttpMethod.POST, "/api/v1/applications").hasAnyRole("USER", "GPT_CLIENT")
.requestMatchers(HttpMethod.GET, "/api/v1/applications/*").hasAnyRole("USER", "GPT_CLIENT")
.requestMatchers(HttpMethod.PATCH, "/api/v1/applications/*/status").hasAnyRole("USER", "GPT_CLIENT")
.requestMatchers(HttpMethod.GET, "/api/v1/google-drive/status").hasAnyRole("USER", "GPT_CLIENT")
.requestMatchers(HttpMethod.GET, "/api/v1/google-drive/base-resumes").hasAnyRole("USER", "GPT_CLIENT")
.requestMatchers(HttpMethod.GET, "/api/v1/google-drive/base-resumes/*/content").hasAnyRole("USER", "GPT_CLIENT")
.requestMatchers(HttpMethod.GET, "/api/v1/google-drive/applications/*/generated-resumes/content").hasAnyRole("USER", "GPT_CLIENT")
// ROLE_USER endpoints: all remaining application APIs under /api/v1/**
// (including /api/v1/auth/me and /api/v1/auth/me/**).
.requestMatchers("/api/v1/**").hasRole("USER")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDate;
Expand Down Expand Up @@ -42,6 +43,7 @@ public ApplicationController(ApplicationService applicationService, LinkMetadata
@ApiResponse(responseCode = "400", description = "Validation error")
}
)
@PreAuthorize("hasRole('USER') or hasAuthority('SCOPE_write:applications')")
@PostMapping
public ResponseEntity<ApplicationResponse> create(@Valid @RequestBody ApplicationRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(applicationService.create(request));
Expand All @@ -56,6 +58,7 @@ public ResponseEntity<ApplicationResponse> create(@Valid @RequestBody Applicatio
@ApiResponse(responseCode = "404", description = "Application not found")
}
)
@PreAuthorize("hasRole('USER') or hasAuthority('SCOPE_read:applications')")
@GetMapping("/{id}")
public ResponseEntity<ApplicationResponse> getById(
@Parameter(description = "Application ID", required = true) @PathVariable UUID id) {
Expand Down Expand Up @@ -88,6 +91,7 @@ public ResponseEntity<ApplicationResponse> update(
@ApiResponse(responseCode = "404", description = "Application not found")
}
)
@PreAuthorize("hasRole('USER') or hasAuthority('SCOPE_write:applications')")
@PatchMapping("/{id}/status")
public ResponseEntity<ApplicationResponse> updateStatus(
@Parameter(description = "Application ID", required = true) @PathVariable UUID id,
Expand Down Expand Up @@ -163,6 +167,7 @@ public ResponseEntity<ApplicationResponse> archive(
content = @Content(schema = @Schema(implementation = ApplicationPageResponse.class)))
}
)
@PreAuthorize("hasRole('USER') or hasAuthority('SCOPE_read:applications')")
@GetMapping
public ResponseEntity<ApplicationPageResponse> getAll(
@Parameter(description = "Filter by status") @RequestParam(required = false) String status,
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/jobtracker/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

@Tag(name = "Auth", description = "Authentication and user management endpoints")
Expand Down Expand Up @@ -205,6 +206,7 @@ public ResponseEntity<MessageResponse> logout(
@ApiResponse(responseCode = "401", description = "Not authenticated")
}
)
@PreAuthorize("hasRole('USER') or hasAuthority('SCOPE_read:profile')")
@GetMapping("/me")
public ResponseEntity<UserResponse> me() {
return ResponseEntity.ok(authMapper.toUserResponse(securityUtils.getCurrentUser()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ public void oauthCallback(@RequestParam(required = false) String state,
responses = @ApiResponse(responseCode = "200", description = "Current Google Drive integration status",
content = @Content(schema = @Schema(implementation = GoogleDriveStatusResponse.class)))
)
@PreAuthorize("hasRole('BETA')")
@PreAuthorize("hasRole('BETA') and (hasRole('USER') or hasAuthority('SCOPE_read:google-drive'))")
@GetMapping("/status")
public ResponseEntity<GoogleDriveStatusResponse> getStatus() {
return ResponseEntity.ok(googleDriveService.getStatus());
Expand Down Expand Up @@ -114,7 +114,7 @@ public ResponseEntity<GoogleDriveBaseResumeResponse> addBaseResume(
responses = @ApiResponse(responseCode = "200", description = "List of base resumes",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = BaseResumeResponse.class))))
)
@PreAuthorize("hasRole('BETA')")
@PreAuthorize("hasRole('BETA') and (hasRole('USER') or hasAuthority('SCOPE_read:resume'))")
@GetMapping("/base-resumes")
public ResponseEntity<List<BaseResumeResponse>> listBaseResumes() {
return ResponseEntity.ok(googleDriveService.listBaseResumes());
Expand All @@ -139,7 +139,7 @@ public ResponseEntity<MessageResponse> deleteBaseResume(@PathVariable UUID baseR
@ApiResponse(responseCode = "404", description = "Base resume not found")
}
)
@PreAuthorize("hasRole('BETA')")
@PreAuthorize("hasRole('BETA') and (hasRole('USER') or hasAuthority('SCOPE_read:resume'))")
@GetMapping("/base-resumes/{resumeId}/content")
public ResponseEntity<BaseResumeContentResponse> getBaseResumeContent(
@Parameter(description = "UUID of the base resume. NOT the filename.",
Expand All @@ -157,7 +157,7 @@ public ResponseEntity<BaseResumeContentResponse> getBaseResumeContent(
@ApiResponse(responseCode = "404", description = "Application or generated resume not found")
}
)
@PreAuthorize("hasRole('BETA')")
@PreAuthorize("hasRole('BETA') and (hasRole('USER') or hasAuthority('SCOPE_read:resume'))")
@GetMapping("/applications/{applicationId}/generated-resumes/content")
public ResponseEntity<ResumeGenerationService.GeneratedResumeContentResponse> getGeneratedResumeContent(
@Parameter(description = "UUID of the application.",
Expand Down
Loading