feat(04-02): wire Spring Security filter chain with JWT auth, bootstrap registration, and refresh endpoint
- JwtAuthenticationFilter extracts JWT from Authorization header or query param, validates via JwtService
- SecurityConfig creates stateless SecurityFilterChain with public/protected endpoint split
- AgentRegistrationController requires bootstrap token, returns accessToken + refreshToken + serverPublicKey
- New POST /agents/{id}/refresh endpoint issues new access JWT from valid refresh token
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,20 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.config.AgentRegistryConfig;
|
||||
import com.cameleer3.server.app.security.BootstrapTokenValidator;
|
||||
import com.cameleer3.server.core.agent.AgentInfo;
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer3.server.core.agent.AgentState;
|
||||
import com.cameleer3.server.core.security.Ed25519SigningService;
|
||||
import com.cameleer3.server.core.security.InvalidTokenException;
|
||||
import com.cameleer3.server.core.security.JwtService;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -29,7 +34,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Agent registration, heartbeat, and listing endpoints.
|
||||
* Agent registration, heartbeat, listing, and token refresh endpoints.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/agents")
|
||||
@@ -37,25 +42,48 @@ import java.util.Map;
|
||||
public class AgentRegistrationController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AgentRegistrationController.class);
|
||||
private static final String BEARER_PREFIX = "Bearer ";
|
||||
|
||||
private final AgentRegistryService registryService;
|
||||
private final AgentRegistryConfig config;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final BootstrapTokenValidator bootstrapTokenValidator;
|
||||
private final JwtService jwtService;
|
||||
private final Ed25519SigningService ed25519SigningService;
|
||||
|
||||
public AgentRegistrationController(AgentRegistryService registryService,
|
||||
AgentRegistryConfig config,
|
||||
ObjectMapper objectMapper) {
|
||||
ObjectMapper objectMapper,
|
||||
BootstrapTokenValidator bootstrapTokenValidator,
|
||||
JwtService jwtService,
|
||||
Ed25519SigningService ed25519SigningService) {
|
||||
this.registryService = registryService;
|
||||
this.config = config;
|
||||
this.objectMapper = objectMapper;
|
||||
this.bootstrapTokenValidator = bootstrapTokenValidator;
|
||||
this.jwtService = jwtService;
|
||||
this.ed25519SigningService = ed25519SigningService;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
@Operation(summary = "Register an agent",
|
||||
description = "Registers a new agent or re-registers an existing one")
|
||||
description = "Registers a new agent or re-registers an existing one. "
|
||||
+ "Requires bootstrap token in Authorization header.")
|
||||
@ApiResponse(responseCode = "200", description = "Agent registered successfully")
|
||||
@ApiResponse(responseCode = "400", description = "Invalid registration payload")
|
||||
public ResponseEntity<String> register(@RequestBody String body) throws JsonProcessingException {
|
||||
@ApiResponse(responseCode = "401", description = "Missing or invalid bootstrap token")
|
||||
public ResponseEntity<String> register(@RequestBody String body,
|
||||
HttpServletRequest request) throws JsonProcessingException {
|
||||
// Validate bootstrap token
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
String bootstrapToken = null;
|
||||
if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
|
||||
bootstrapToken = authHeader.substring(BEARER_PREFIX.length());
|
||||
}
|
||||
if (bootstrapToken == null || !bootstrapTokenValidator.validate(bootstrapToken)) {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
|
||||
JsonNode node = objectMapper.readTree(body);
|
||||
|
||||
String agentId = getRequiredField(node, "agentId");
|
||||
@@ -88,11 +116,61 @@ public class AgentRegistrationController {
|
||||
AgentInfo agent = registryService.register(agentId, name, group, version, routeIds, capabilities);
|
||||
log.info("Agent registered: {} (name={}, group={})", agentId, name, group);
|
||||
|
||||
// Issue JWT tokens
|
||||
String accessToken = jwtService.createAccessToken(agentId, group);
|
||||
String refreshToken = jwtService.createRefreshToken(agentId, group);
|
||||
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("agentId", agent.id());
|
||||
response.put("sseEndpoint", "/api/v1/agents/" + agentId + "/events");
|
||||
response.put("heartbeatIntervalMs", config.getHeartbeatIntervalMs());
|
||||
response.put("serverPublicKey", null);
|
||||
response.put("serverPublicKey", ed25519SigningService.getPublicKeyBase64());
|
||||
response.put("accessToken", accessToken);
|
||||
response.put("refreshToken", refreshToken);
|
||||
|
||||
return ResponseEntity.ok(objectMapper.writeValueAsString(response));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/refresh")
|
||||
@Operation(summary = "Refresh access token",
|
||||
description = "Issues a new access JWT from a valid refresh token")
|
||||
@ApiResponse(responseCode = "200", description = "New access token issued")
|
||||
@ApiResponse(responseCode = "401", description = "Invalid or expired refresh token")
|
||||
@ApiResponse(responseCode = "404", description = "Agent not found")
|
||||
public ResponseEntity<String> refresh(@PathVariable String id,
|
||||
@RequestBody String body) throws JsonProcessingException {
|
||||
JsonNode node = objectMapper.readTree(body);
|
||||
String refreshToken = node.has("refreshToken") ? node.get("refreshToken").asText() : null;
|
||||
|
||||
if (refreshToken == null || refreshToken.isBlank()) {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
|
||||
// Validate refresh token
|
||||
String agentId;
|
||||
try {
|
||||
agentId = jwtService.validateRefreshToken(refreshToken);
|
||||
} catch (InvalidTokenException e) {
|
||||
log.debug("Refresh token validation failed: {}", e.getMessage());
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
|
||||
// Verify agent ID in path matches token
|
||||
if (!id.equals(agentId)) {
|
||||
log.debug("Refresh token agent ID mismatch: path={}, token={}", id, agentId);
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
|
||||
// Verify agent exists
|
||||
AgentInfo agent = registryService.findById(agentId);
|
||||
if (agent == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String newAccessToken = jwtService.createAccessToken(agentId, agent.group());
|
||||
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("accessToken", newAccessToken);
|
||||
|
||||
return ResponseEntity.ok(objectMapper.writeValueAsString(response));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.cameleer3.server.app.security;
|
||||
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer3.server.core.security.JwtService;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* JWT authentication filter that extracts and validates JWT tokens from
|
||||
* the {@code Authorization: Bearer} header or the {@code token} query parameter.
|
||||
* <p>
|
||||
* Not annotated {@code @Component} -- constructed explicitly in {@link SecurityConfig}
|
||||
* to avoid double filter registration.
|
||||
*/
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
|
||||
private static final String BEARER_PREFIX = "Bearer ";
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final AgentRegistryService agentRegistryService;
|
||||
|
||||
public JwtAuthenticationFilter(JwtService jwtService, AgentRegistryService agentRegistryService) {
|
||||
this.jwtService = jwtService;
|
||||
this.agentRegistryService = agentRegistryService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
String token = extractToken(request);
|
||||
|
||||
if (token != null) {
|
||||
try {
|
||||
String agentId = jwtService.validateAndExtractAgentId(token);
|
||||
if (agentRegistryService.findById(agentId) != null) {
|
||||
UsernamePasswordAuthenticationToken auth =
|
||||
new UsernamePasswordAuthenticationToken(agentId, null, List.of());
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
} else {
|
||||
log.debug("JWT valid but agent not found: {}", agentId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("JWT validation failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private String extractToken(HttpServletRequest request) {
|
||||
// Check Authorization header first
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
|
||||
return authHeader.substring(BEARER_PREFIX.length());
|
||||
}
|
||||
|
||||
// Fall back to query parameter (for SSE EventSource API)
|
||||
return request.getParameter("token");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.cameleer3.server.app.security;
|
||||
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer3.server.core.security.JwtService;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
/**
|
||||
* Spring Security configuration for JWT-based stateless authentication.
|
||||
* <p>
|
||||
* Public endpoints: health, agent registration, refresh, API docs, Swagger UI.
|
||||
* All other endpoints require a valid JWT access token.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http,
|
||||
JwtService jwtService,
|
||||
AgentRegistryService registryService) throws Exception {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.formLogin(AbstractHttpConfigurer::disable)
|
||||
.httpBasic(AbstractHttpConfigurer::disable)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(
|
||||
"/api/v1/health",
|
||||
"/api/v1/agents/register",
|
||||
"/api/v1/agents/*/refresh",
|
||||
"/api/v1/api-docs/**",
|
||||
"/api/v1/swagger-ui/**",
|
||||
"/swagger-ui/**",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-ui.html"
|
||||
).permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(
|
||||
new JwtAuthenticationFilter(jwtService, registryService),
|
||||
UsernamePasswordAuthenticationFilter.class
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user