Add RBAC with role-based endpoint authorization and OIDC support
Implement three-phase security upgrade: Phase 1 - RBAC: Extend JWT with roles claim, populate Spring GrantedAuthority in filter, enforce role-based access (AGENT for data/heartbeat/SSE, VIEWER+ for search/diagrams, OPERATOR+ for commands, ADMIN for user management). Configurable JWT secret via CAMELEER_JWT_SECRET env var for token persistence across restarts. Phase 2 - User persistence: ClickHouse users table with ReplacingMergeTree, UserRepository interface + ClickHouse impl, UserAdminController for CRUD at /api/v1/admin/users. Local login upserts user on each authentication. Phase 3 - OIDC: Token exchange flow where SPA sends auth code, server exchanges it server-side (keeping client_secret secure), validates id_token via JWKS, resolves roles (DB override > OIDC claim > default), issues internal JWT. Conditional on CAMELEER_OIDC_ENABLED=true. Uses oauth2-oidc-sdk for standards compliance. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,11 @@
|
|||||||
<artifactId>nimbus-jose-jwt</artifactId>
|
<artifactId>nimbus-jose-jwt</artifactId>
|
||||||
<version>9.47</version>
|
<version>9.47</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.nimbusds</groupId>
|
||||||
|
<artifactId>oauth2-oidc-sdk</artifactId>
|
||||||
|
<version>11.23.1</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ import java.util.stream.Collectors;
|
|||||||
public class ClickHouseConfig {
|
public class ClickHouseConfig {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(ClickHouseConfig.class);
|
private static final Logger log = LoggerFactory.getLogger(ClickHouseConfig.class);
|
||||||
private static final String[] SCHEMA_FILES = {"clickhouse/01-schema.sql", "clickhouse/02-search-columns.sql"};
|
private static final String[] SCHEMA_FILES = {
|
||||||
|
"clickhouse/01-schema.sql", "clickhouse/02-search-columns.sql", "clickhouse/03-users.sql"
|
||||||
|
};
|
||||||
|
|
||||||
private final DataSource dataSource;
|
private final DataSource dataSource;
|
||||||
|
|
||||||
|
|||||||
@@ -116,9 +116,10 @@ public class AgentRegistrationController {
|
|||||||
AgentInfo agent = registryService.register(agentId, name, group, version, routeIds, capabilities);
|
AgentInfo agent = registryService.register(agentId, name, group, version, routeIds, capabilities);
|
||||||
log.info("Agent registered: {} (name={}, group={})", agentId, name, group);
|
log.info("Agent registered: {} (name={}, group={})", agentId, name, group);
|
||||||
|
|
||||||
// Issue JWT tokens
|
// Issue JWT tokens with AGENT role
|
||||||
String accessToken = jwtService.createAccessToken(agentId, group);
|
java.util.List<String> roles = java.util.List.of("AGENT");
|
||||||
String refreshToken = jwtService.createRefreshToken(agentId, group);
|
String accessToken = jwtService.createAccessToken(agentId, group, roles);
|
||||||
|
String refreshToken = jwtService.createRefreshToken(agentId, group, roles);
|
||||||
|
|
||||||
Map<String, Object> response = new LinkedHashMap<>();
|
Map<String, Object> response = new LinkedHashMap<>();
|
||||||
response.put("agentId", agent.id());
|
response.put("agentId", agent.id());
|
||||||
@@ -147,14 +148,16 @@ public class AgentRegistrationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate refresh token
|
// Validate refresh token
|
||||||
String agentId;
|
com.cameleer3.server.core.security.JwtService.JwtValidationResult result;
|
||||||
try {
|
try {
|
||||||
agentId = jwtService.validateRefreshToken(refreshToken);
|
result = jwtService.validateRefreshToken(refreshToken);
|
||||||
} catch (InvalidTokenException e) {
|
} catch (InvalidTokenException e) {
|
||||||
log.debug("Refresh token validation failed: {}", e.getMessage());
|
log.debug("Refresh token validation failed: {}", e.getMessage());
|
||||||
return ResponseEntity.status(401).build();
|
return ResponseEntity.status(401).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String agentId = result.subject();
|
||||||
|
|
||||||
// Verify agent ID in path matches token
|
// Verify agent ID in path matches token
|
||||||
if (!id.equals(agentId)) {
|
if (!id.equals(agentId)) {
|
||||||
log.debug("Refresh token agent ID mismatch: path={}, token={}", id, agentId);
|
log.debug("Refresh token agent ID mismatch: path={}, token={}", id, agentId);
|
||||||
@@ -167,7 +170,10 @@ public class AgentRegistrationController {
|
|||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
String newAccessToken = jwtService.createAccessToken(agentId, agent.group());
|
// Preserve roles from refresh token
|
||||||
|
java.util.List<String> roles = result.roles().isEmpty()
|
||||||
|
? java.util.List.of("AGENT") : result.roles();
|
||||||
|
String newAccessToken = jwtService.createAccessToken(agentId, agent.group(), roles);
|
||||||
|
|
||||||
Map<String, Object> response = new LinkedHashMap<>();
|
Map<String, Object> response = new LinkedHashMap<>();
|
||||||
response.put("accessToken", newAccessToken);
|
response.put("accessToken", newAccessToken);
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.security.UserInfo;
|
||||||
|
import com.cameleer3.server.core.security.UserRepository;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin endpoints for user management.
|
||||||
|
* Protected by {@code ROLE_ADMIN} via SecurityConfig URL patterns.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/admin/users")
|
||||||
|
@Tag(name = "User Admin", description = "User management (ADMIN only)")
|
||||||
|
public class UserAdminController {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public UserAdminController(UserRepository userRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "List all users")
|
||||||
|
@ApiResponse(responseCode = "200", description = "User list returned")
|
||||||
|
public ResponseEntity<List<UserInfo>> listUsers() {
|
||||||
|
return ResponseEntity.ok(userRepository.findAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{userId}")
|
||||||
|
@Operation(summary = "Get user by ID")
|
||||||
|
@ApiResponse(responseCode = "200", description = "User found")
|
||||||
|
@ApiResponse(responseCode = "404", description = "User not found")
|
||||||
|
public ResponseEntity<UserInfo> getUser(@PathVariable String userId) {
|
||||||
|
return userRepository.findById(userId)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{userId}/roles")
|
||||||
|
@Operation(summary = "Update user roles")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Roles updated")
|
||||||
|
@ApiResponse(responseCode = "404", description = "User not found")
|
||||||
|
public ResponseEntity<Void> updateRoles(@PathVariable String userId,
|
||||||
|
@RequestBody RolesRequest request) {
|
||||||
|
if (userRepository.findById(userId).isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
userRepository.updateRoles(userId, request.roles());
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{userId}")
|
||||||
|
@Operation(summary = "Delete user")
|
||||||
|
@ApiResponse(responseCode = "204", description = "User deleted")
|
||||||
|
public ResponseEntity<Void> deleteUser(@PathVariable String userId) {
|
||||||
|
userRepository.delete(userId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RolesRequest(List<String> roles) {}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.cameleer3.server.app.security;
|
|||||||
|
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
import com.cameleer3.server.core.security.JwtService;
|
import com.cameleer3.server.core.security.JwtService;
|
||||||
|
import com.cameleer3.server.core.security.JwtService.JwtValidationResult;
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
@@ -9,6 +10,8 @@ import jakarta.servlet.http.HttpServletResponse;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
@@ -19,6 +22,9 @@ import java.util.List;
|
|||||||
* JWT authentication filter that extracts and validates JWT tokens from
|
* JWT authentication filter that extracts and validates JWT tokens from
|
||||||
* the {@code Authorization: Bearer} header or the {@code token} query parameter.
|
* the {@code Authorization: Bearer} header or the {@code token} query parameter.
|
||||||
* <p>
|
* <p>
|
||||||
|
* Populates Spring Security {@code GrantedAuthority} from the JWT {@code roles} claim.
|
||||||
|
* Agent tokens without roles get {@code ROLE_AGENT}; UI tokens get authorities from the claim.
|
||||||
|
* <p>
|
||||||
* Not annotated {@code @Component} -- constructed explicitly in {@link SecurityConfig}
|
* Not annotated {@code @Component} -- constructed explicitly in {@link SecurityConfig}
|
||||||
* to avoid double filter registration.
|
* to avoid double filter registration.
|
||||||
*/
|
*/
|
||||||
@@ -43,15 +49,24 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
|
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
try {
|
try {
|
||||||
String subject = jwtService.validateAndExtractAgentId(token);
|
JwtValidationResult result = jwtService.validateAccessToken(token);
|
||||||
|
String subject = result.subject();
|
||||||
|
|
||||||
if (subject.startsWith("ui:")) {
|
if (subject.startsWith("ui:")) {
|
||||||
// UI user token — authenticate directly without agent registry lookup
|
// UI user token — authenticate with roles from JWT
|
||||||
|
List<GrantedAuthority> authorities = toAuthorities(result.roles());
|
||||||
UsernamePasswordAuthenticationToken auth =
|
UsernamePasswordAuthenticationToken auth =
|
||||||
new UsernamePasswordAuthenticationToken(subject, null, List.of());
|
new UsernamePasswordAuthenticationToken(subject, null, authorities);
|
||||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||||
} else if (agentRegistryService.findById(subject) != null) {
|
} else if (agentRegistryService.findById(subject) != null) {
|
||||||
|
// Agent token — use roles from JWT, default to AGENT if empty
|
||||||
|
List<String> roles = result.roles();
|
||||||
|
if (roles.isEmpty()) {
|
||||||
|
roles = List.of("AGENT");
|
||||||
|
}
|
||||||
|
List<GrantedAuthority> authorities = toAuthorities(roles);
|
||||||
UsernamePasswordAuthenticationToken auth =
|
UsernamePasswordAuthenticationToken auth =
|
||||||
new UsernamePasswordAuthenticationToken(subject, null, List.of());
|
new UsernamePasswordAuthenticationToken(subject, null, authorities);
|
||||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||||
} else {
|
} else {
|
||||||
log.debug("JWT valid but agent not found: {}", subject);
|
log.debug("JWT valid but agent not found: {}", subject);
|
||||||
@@ -64,14 +79,17 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
chain.doFilter(request, response);
|
chain.doFilter(request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<GrantedAuthority> toAuthorities(List<String> roles) {
|
||||||
|
return roles.stream()
|
||||||
|
.map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
private String extractToken(HttpServletRequest request) {
|
private String extractToken(HttpServletRequest request) {
|
||||||
// Check Authorization header first
|
|
||||||
String authHeader = request.getHeader("Authorization");
|
String authHeader = request.getHeader("Authorization");
|
||||||
if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
|
if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
|
||||||
return authHeader.substring(BEARER_PREFIX.length());
|
return authHeader.substring(BEARER_PREFIX.length());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to query parameter (for SSE EventSource API)
|
|
||||||
return request.getParameter("token");
|
return request.getParameter("token");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,21 +11,28 @@ import com.nimbusds.jose.crypto.MACSigner;
|
|||||||
import com.nimbusds.jose.crypto.MACVerifier;
|
import com.nimbusds.jose.crypto.MACVerifier;
|
||||||
import com.nimbusds.jwt.JWTClaimsSet;
|
import com.nimbusds.jwt.JWTClaimsSet;
|
||||||
import com.nimbusds.jwt.SignedJWT;
|
import com.nimbusds.jwt.SignedJWT;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.text.ParseException;
|
import java.text.ParseException;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HMAC-SHA256 JWT implementation using Nimbus JOSE+JWT.
|
* HMAC-SHA256 JWT implementation using Nimbus JOSE+JWT.
|
||||||
* <p>
|
* <p>
|
||||||
* Generates a random 256-bit secret in the constructor (ephemeral per server instance).
|
* If {@code security.jwt-secret} is configured, uses that secret (Base64-decoded or raw bytes
|
||||||
* Creates access tokens (1h default) and refresh tokens (7d default) with type claims
|
* if not valid Base64). Otherwise generates a random 256-bit secret (ephemeral per server instance).
|
||||||
* to distinguish between the two.
|
* <p>
|
||||||
|
* Embeds a {@code roles} array claim in every token for RBAC.
|
||||||
*/
|
*/
|
||||||
public class JwtServiceImpl implements JwtService {
|
public class JwtServiceImpl implements JwtService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(JwtServiceImpl.class);
|
||||||
|
|
||||||
private final byte[] secret;
|
private final byte[] secret;
|
||||||
private final JWSSigner signer;
|
private final JWSSigner signer;
|
||||||
private final JWSVerifier verifier;
|
private final JWSVerifier verifier;
|
||||||
@@ -33,8 +40,17 @@ public class JwtServiceImpl implements JwtService {
|
|||||||
|
|
||||||
public JwtServiceImpl(SecurityProperties properties) {
|
public JwtServiceImpl(SecurityProperties properties) {
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
this.secret = new byte[32]; // 256-bit
|
|
||||||
new SecureRandom().nextBytes(secret);
|
String configuredSecret = properties.getJwtSecret();
|
||||||
|
if (configuredSecret != null && !configuredSecret.isBlank()) {
|
||||||
|
this.secret = decodeSecret(configuredSecret);
|
||||||
|
log.info("JWT signing secret loaded from configuration");
|
||||||
|
} else {
|
||||||
|
this.secret = new byte[32]; // 256-bit
|
||||||
|
new SecureRandom().nextBytes(secret);
|
||||||
|
log.info("JWT signing secret generated randomly (tokens will not survive restarts)");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.signer = new MACSigner(secret);
|
this.signer = new MACSigner(secret);
|
||||||
this.verifier = new MACVerifier(secret);
|
this.verifier = new MACVerifier(secret);
|
||||||
@@ -44,31 +60,38 @@ public class JwtServiceImpl implements JwtService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String createAccessToken(String agentId, String group) {
|
public String createAccessToken(String subject, String group, List<String> roles) {
|
||||||
return createToken(agentId, group, "access", properties.getAccessTokenExpiryMs());
|
return createToken(subject, group, roles, "access", properties.getAccessTokenExpiryMs());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String createRefreshToken(String agentId, String group) {
|
public String createRefreshToken(String subject, String group, List<String> roles) {
|
||||||
return createToken(agentId, group, "refresh", properties.getRefreshTokenExpiryMs());
|
return createToken(subject, group, roles, "refresh", properties.getRefreshTokenExpiryMs());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String validateAndExtractAgentId(String token) {
|
public JwtValidationResult validateAccessToken(String token) {
|
||||||
return validateToken(token, "access");
|
return validateToken(token, "access");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String validateRefreshToken(String token) {
|
public JwtValidationResult validateRefreshToken(String token) {
|
||||||
return validateToken(token, "refresh");
|
return validateToken(token, "refresh");
|
||||||
}
|
}
|
||||||
|
|
||||||
private String createToken(String agentId, String group, String type, long expiryMs) {
|
@Override
|
||||||
|
public String validateAndExtractAgentId(String token) {
|
||||||
|
return validateAccessToken(token).subject();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createToken(String subject, String group, List<String> roles,
|
||||||
|
String type, long expiryMs) {
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
JWTClaimsSet claims = new JWTClaimsSet.Builder()
|
JWTClaimsSet claims = new JWTClaimsSet.Builder()
|
||||||
.subject(agentId)
|
.subject(subject)
|
||||||
.claim("group", group)
|
.claim("group", group)
|
||||||
.claim("type", type)
|
.claim("type", type)
|
||||||
|
.claim("roles", roles)
|
||||||
.issueTime(Date.from(now))
|
.issueTime(Date.from(now))
|
||||||
.expirationTime(Date.from(now.plusMillis(expiryMs)))
|
.expirationTime(Date.from(now.plusMillis(expiryMs)))
|
||||||
.build();
|
.build();
|
||||||
@@ -82,7 +105,8 @@ public class JwtServiceImpl implements JwtService {
|
|||||||
return signedJWT.serialize();
|
return signedJWT.serialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String validateToken(String token, String expectedType) {
|
@SuppressWarnings("unchecked")
|
||||||
|
private JwtValidationResult validateToken(String token, String expectedType) {
|
||||||
try {
|
try {
|
||||||
SignedJWT signedJWT = SignedJWT.parse(token);
|
SignedJWT signedJWT = SignedJWT.parse(token);
|
||||||
|
|
||||||
@@ -92,13 +116,11 @@ public class JwtServiceImpl implements JwtService {
|
|||||||
|
|
||||||
JWTClaimsSet claims = signedJWT.getJWTClaimsSet();
|
JWTClaimsSet claims = signedJWT.getJWTClaimsSet();
|
||||||
|
|
||||||
// Check expiration
|
|
||||||
Date expiration = claims.getExpirationTime();
|
Date expiration = claims.getExpirationTime();
|
||||||
if (expiration == null || expiration.before(new Date())) {
|
if (expiration == null || expiration.before(new Date())) {
|
||||||
throw new InvalidTokenException("Token has expired");
|
throw new InvalidTokenException("Token has expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check type
|
|
||||||
String type = claims.getStringClaim("type");
|
String type = claims.getStringClaim("type");
|
||||||
if (!expectedType.equals(type)) {
|
if (!expectedType.equals(type)) {
|
||||||
throw new InvalidTokenException(
|
throw new InvalidTokenException(
|
||||||
@@ -107,14 +129,48 @@ public class JwtServiceImpl implements JwtService {
|
|||||||
|
|
||||||
String subject = claims.getSubject();
|
String subject = claims.getSubject();
|
||||||
if (subject == null || subject.isBlank()) {
|
if (subject == null || subject.isBlank()) {
|
||||||
throw new InvalidTokenException("Token has no subject (agentId)");
|
throw new InvalidTokenException("Token has no subject");
|
||||||
}
|
}
|
||||||
|
|
||||||
return subject;
|
String group = claims.getStringClaim("group");
|
||||||
|
|
||||||
|
// Extract roles — may be absent in legacy tokens
|
||||||
|
List<String> roles;
|
||||||
|
Object rolesClaim = claims.getClaim("roles");
|
||||||
|
if (rolesClaim instanceof List<?>) {
|
||||||
|
roles = ((List<?>) rolesClaim).stream()
|
||||||
|
.map(Object::toString)
|
||||||
|
.toList();
|
||||||
|
} else {
|
||||||
|
roles = List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JwtValidationResult(subject, group, roles);
|
||||||
} catch (ParseException e) {
|
} catch (ParseException e) {
|
||||||
throw new InvalidTokenException("Failed to parse JWT", e);
|
throw new InvalidTokenException("Failed to parse JWT", e);
|
||||||
} catch (JOSEException e) {
|
} catch (JOSEException e) {
|
||||||
throw new InvalidTokenException("Failed to verify JWT signature", e);
|
throw new InvalidTokenException("Failed to verify JWT signature", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static byte[] decodeSecret(String secret) {
|
||||||
|
try {
|
||||||
|
byte[] decoded = Base64.getDecoder().decode(secret);
|
||||||
|
if (decoded.length >= 32) {
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
// Not valid Base64 — use raw bytes
|
||||||
|
}
|
||||||
|
byte[] raw = secret.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
if (raw.length < 32) {
|
||||||
|
// Pad to 32 bytes with SHA-256 hash
|
||||||
|
try {
|
||||||
|
return java.security.MessageDigest.getInstance("SHA-256").digest(raw);
|
||||||
|
} catch (java.security.NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 not available", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package com.cameleer3.server.app.security;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.security.JwtService;
|
||||||
|
import com.cameleer3.server.core.security.UserInfo;
|
||||||
|
import com.cameleer3.server.core.security.UserRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OIDC authentication endpoints for the UI.
|
||||||
|
* <p>
|
||||||
|
* Only active when {@code security.oidc.enabled=true}.
|
||||||
|
* The SPA initiates the authorization code flow, then sends the code here
|
||||||
|
* for server-side token exchange (keeping client_secret secure).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/auth/oidc")
|
||||||
|
@ConditionalOnProperty(prefix = "security.oidc", name = "enabled", havingValue = "true")
|
||||||
|
public class OidcAuthController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(OidcAuthController.class);
|
||||||
|
|
||||||
|
private final OidcTokenExchanger tokenExchanger;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final SecurityProperties properties;
|
||||||
|
|
||||||
|
public OidcAuthController(OidcTokenExchanger tokenExchanger,
|
||||||
|
JwtService jwtService,
|
||||||
|
UserRepository userRepository,
|
||||||
|
SecurityProperties properties) {
|
||||||
|
this.tokenExchanger = tokenExchanger;
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns OIDC configuration for the SPA to initiate the authorization code flow.
|
||||||
|
*/
|
||||||
|
@GetMapping("/config")
|
||||||
|
public ResponseEntity<?> getConfig() {
|
||||||
|
try {
|
||||||
|
SecurityProperties.Oidc oidc = properties.getOidc();
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"issuer", oidc.getIssuerUri(),
|
||||||
|
"clientId", oidc.getClientId(),
|
||||||
|
"authorizationEndpoint", tokenExchanger.getAuthorizationEndpoint()
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to retrieve OIDC config: {}", e.getMessage());
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(Map.of("message", "Failed to retrieve OIDC provider metadata"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchanges an OIDC authorization code for internal Cameleer JWTs.
|
||||||
|
* <p>
|
||||||
|
* Role resolution priority:
|
||||||
|
* 1. ClickHouse user table (admin-assigned override)
|
||||||
|
* 2. OIDC token claim
|
||||||
|
* 3. Default roles from config
|
||||||
|
*/
|
||||||
|
@PostMapping("/callback")
|
||||||
|
public ResponseEntity<?> callback(@RequestBody CallbackRequest request) {
|
||||||
|
try {
|
||||||
|
OidcTokenExchanger.OidcUserInfo oidcUser =
|
||||||
|
tokenExchanger.exchange(request.code(), request.redirectUri());
|
||||||
|
|
||||||
|
String userId = "oidc:" + oidcUser.subject();
|
||||||
|
String issuerHost = URI.create(properties.getOidc().getIssuerUri()).getHost();
|
||||||
|
String provider = "oidc:" + issuerHost;
|
||||||
|
|
||||||
|
// Resolve roles: DB override > OIDC claim > default
|
||||||
|
List<String> roles = resolveRoles(userId, oidcUser.roles());
|
||||||
|
|
||||||
|
// Upsert user
|
||||||
|
userRepository.upsert(new UserInfo(
|
||||||
|
userId, provider, oidcUser.email(), oidcUser.name(), roles, Instant.now()));
|
||||||
|
|
||||||
|
// Issue internal tokens
|
||||||
|
String accessToken = jwtService.createAccessToken(userId, "ui", roles);
|
||||||
|
String refreshToken = jwtService.createRefreshToken(userId, "ui", roles);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"accessToken", accessToken,
|
||||||
|
"refreshToken", refreshToken
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("OIDC callback failed: {}", e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(401)
|
||||||
|
.body(Map.of("message", "OIDC authentication failed: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> resolveRoles(String userId, List<String> oidcRoles) {
|
||||||
|
// 1. Check for admin-assigned override in user store
|
||||||
|
Optional<UserInfo> existing = userRepository.findById(userId);
|
||||||
|
if (existing.isPresent() && !existing.get().roles().isEmpty()) {
|
||||||
|
return existing.get().roles();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Roles from OIDC token
|
||||||
|
if (!oidcRoles.isEmpty()) {
|
||||||
|
return oidcRoles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Default roles
|
||||||
|
return properties.getOidc().getDefaultRoles();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CallbackRequest(String code, String redirectUri) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package com.cameleer3.server.app.security;
|
||||||
|
|
||||||
|
import com.nimbusds.jose.JOSEException;
|
||||||
|
import com.nimbusds.jose.JWSAlgorithm;
|
||||||
|
import com.nimbusds.jose.jwk.source.JWKSource;
|
||||||
|
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
|
||||||
|
import com.nimbusds.jose.proc.BadJOSEException;
|
||||||
|
import com.nimbusds.jose.proc.JWSKeySelector;
|
||||||
|
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
|
||||||
|
import com.nimbusds.jose.proc.SecurityContext;
|
||||||
|
import com.nimbusds.jwt.JWTClaimsSet;
|
||||||
|
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
|
||||||
|
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
||||||
|
import com.nimbusds.oauth2.sdk.AuthorizationCode;
|
||||||
|
import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant;
|
||||||
|
import com.nimbusds.oauth2.sdk.TokenRequest;
|
||||||
|
import com.nimbusds.oauth2.sdk.TokenResponse;
|
||||||
|
import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
|
||||||
|
import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic;
|
||||||
|
import com.nimbusds.oauth2.sdk.auth.Secret;
|
||||||
|
import com.nimbusds.oauth2.sdk.id.ClientID;
|
||||||
|
import com.nimbusds.oauth2.sdk.id.Issuer;
|
||||||
|
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchanges OIDC authorization codes for validated user information.
|
||||||
|
* <p>
|
||||||
|
* Fetches and caches the OIDC provider discovery metadata, exchanges the auth code
|
||||||
|
* for tokens at the provider's token endpoint, and validates the id_token using JWKS.
|
||||||
|
*/
|
||||||
|
public class OidcTokenExchanger {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(OidcTokenExchanger.class);
|
||||||
|
|
||||||
|
private final SecurityProperties.Oidc oidcConfig;
|
||||||
|
private volatile OIDCProviderMetadata providerMetadata;
|
||||||
|
private volatile ConfigurableJWTProcessor<SecurityContext> jwtProcessor;
|
||||||
|
|
||||||
|
public OidcTokenExchanger(SecurityProperties.Oidc oidcConfig) {
|
||||||
|
this.oidcConfig = oidcConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record OidcUserInfo(String subject, String email, String name, List<String> roles) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchanges an authorization code for validated user info.
|
||||||
|
*
|
||||||
|
* @param code the authorization code from the OIDC provider
|
||||||
|
* @param redirectUri the redirect URI used in the authorization request
|
||||||
|
* @return validated user information from the id_token
|
||||||
|
*/
|
||||||
|
public OidcUserInfo exchange(String code, String redirectUri) throws Exception {
|
||||||
|
OIDCProviderMetadata metadata = getProviderMetadata();
|
||||||
|
|
||||||
|
// Exchange code for tokens
|
||||||
|
ClientAuthentication clientAuth = new ClientSecretBasic(
|
||||||
|
new ClientID(oidcConfig.getClientId()),
|
||||||
|
new Secret(oidcConfig.getClientSecret())
|
||||||
|
);
|
||||||
|
|
||||||
|
TokenRequest tokenRequest = new TokenRequest(
|
||||||
|
metadata.getTokenEndpointURI(),
|
||||||
|
clientAuth,
|
||||||
|
new AuthorizationCodeGrant(new AuthorizationCode(code), new URI(redirectUri))
|
||||||
|
);
|
||||||
|
|
||||||
|
TokenResponse tokenResponse = TokenResponse.parse(tokenRequest.toHTTPRequest().send());
|
||||||
|
|
||||||
|
if (!tokenResponse.indicatesSuccess()) {
|
||||||
|
String error = tokenResponse.toErrorResponse().getErrorObject().getDescription();
|
||||||
|
throw new IllegalStateException("OIDC token exchange failed: " + error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract id_token from successful response
|
||||||
|
String idTokenStr = tokenResponse.toSuccessResponse().toJSONObject()
|
||||||
|
.getAsString("id_token");
|
||||||
|
if (idTokenStr == null) {
|
||||||
|
throw new IllegalStateException("OIDC response missing id_token");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate id_token
|
||||||
|
JWTClaimsSet claims = getJwtProcessor().process(idTokenStr, null);
|
||||||
|
|
||||||
|
String subject = claims.getSubject();
|
||||||
|
String email = claims.getStringClaim("email");
|
||||||
|
String name = claims.getStringClaim("name");
|
||||||
|
if (name == null) {
|
||||||
|
name = claims.getStringClaim("preferred_username");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> roles = extractRoles(claims, oidcConfig.getRolesClaim());
|
||||||
|
|
||||||
|
log.info("OIDC user authenticated: sub={}, email={}", subject, email);
|
||||||
|
return new OidcUserInfo(subject, email != null ? email : "", name != null ? name : "", roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the provider's authorization endpoint for the SPA to initiate the flow.
|
||||||
|
*/
|
||||||
|
public String getAuthorizationEndpoint() throws Exception {
|
||||||
|
return getProviderMetadata().getAuthorizationEndpointURI().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private List<String> extractRoles(JWTClaimsSet claims, String claimPath) {
|
||||||
|
try {
|
||||||
|
String[] parts = claimPath.split("\\.");
|
||||||
|
Object current = claims.getClaim(parts[0]);
|
||||||
|
|
||||||
|
for (int i = 1; i < parts.length && current instanceof Map; i++) {
|
||||||
|
current = ((Map<String, Object>) current).get(parts[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current instanceof List<?>) {
|
||||||
|
return ((List<?>) current).stream()
|
||||||
|
.map(Object::toString)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Could not extract roles from claim path '{}': {}", claimPath, e.getMessage());
|
||||||
|
}
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private OIDCProviderMetadata getProviderMetadata() throws Exception {
|
||||||
|
if (providerMetadata == null) {
|
||||||
|
synchronized (this) {
|
||||||
|
if (providerMetadata == null) {
|
||||||
|
Issuer issuer = new Issuer(oidcConfig.getIssuerUri());
|
||||||
|
providerMetadata = OIDCProviderMetadata.resolve(issuer);
|
||||||
|
log.info("OIDC provider metadata loaded from {}", oidcConfig.getIssuerUri());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return providerMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ConfigurableJWTProcessor<SecurityContext> getJwtProcessor() throws Exception {
|
||||||
|
if (jwtProcessor == null) {
|
||||||
|
synchronized (this) {
|
||||||
|
if (jwtProcessor == null) {
|
||||||
|
OIDCProviderMetadata metadata = getProviderMetadata();
|
||||||
|
JWKSource<SecurityContext> jwkSource = JWKSourceBuilder
|
||||||
|
.create(metadata.getJWKSetURI().toURL())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Set<JWSAlgorithm> expectedAlgs = Set.of(JWSAlgorithm.RS256, JWSAlgorithm.ES256);
|
||||||
|
JWSKeySelector<SecurityContext> keySelector =
|
||||||
|
new JWSVerificationKeySelector<>(expectedAlgs, jwkSource);
|
||||||
|
|
||||||
|
ConfigurableJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
|
||||||
|
processor.setJWSKeySelector(keySelector);
|
||||||
|
jwtProcessor = processor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jwtProcessor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.cameleer3.server.app.security;
|
package com.cameleer3.server.app.security;
|
||||||
|
|
||||||
import org.springframework.beans.factory.InitializingBean;
|
import org.springframework.beans.factory.InitializingBean;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -40,4 +41,10 @@ public class SecurityBeanConfig {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnProperty(prefix = "security.oidc", name = "enabled", havingValue = "true")
|
||||||
|
public OidcTokenExchanger oidcTokenExchanger(SecurityProperties properties) {
|
||||||
|
return new OidcTokenExchanger(properties.getOidc());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import com.cameleer3.server.core.agent.AgentRegistryService;
|
|||||||
import com.cameleer3.server.core.security.JwtService;
|
import com.cameleer3.server.core.security.JwtService;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
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.configuration.EnableWebSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
@@ -15,15 +17,13 @@ import org.springframework.web.cors.CorsConfiguration;
|
|||||||
import org.springframework.web.cors.CorsConfigurationSource;
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spring Security configuration for JWT-based stateless authentication.
|
* Spring Security configuration for JWT-based stateless authentication with RBAC.
|
||||||
* <p>
|
* <p>
|
||||||
* Public endpoints: health, agent registration, refresh, auth, API docs, Swagger UI, static resources.
|
* Public endpoints: health, agent registration, refresh, auth, API docs, Swagger UI, static resources.
|
||||||
* All other endpoints require a valid JWT access token.
|
* All other endpoints require a valid JWT access token with appropriate roles.
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@@ -41,6 +41,7 @@ public class SecurityConfig {
|
|||||||
.formLogin(AbstractHttpConfigurer::disable)
|
.formLogin(AbstractHttpConfigurer::disable)
|
||||||
.httpBasic(AbstractHttpConfigurer::disable)
|
.httpBasic(AbstractHttpConfigurer::disable)
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
// Public endpoints
|
||||||
.requestMatchers(
|
.requestMatchers(
|
||||||
"/api/v1/health",
|
"/api/v1/health",
|
||||||
"/api/v1/agents/register",
|
"/api/v1/agents/register",
|
||||||
@@ -58,6 +59,32 @@ public class SecurityConfig {
|
|||||||
"/favicon.svg",
|
"/favicon.svg",
|
||||||
"/assets/**"
|
"/assets/**"
|
||||||
).permitAll()
|
).permitAll()
|
||||||
|
|
||||||
|
// Agent-only endpoints
|
||||||
|
.requestMatchers("/api/v1/data/**").hasRole("AGENT")
|
||||||
|
.requestMatchers("/api/v1/agents/*/heartbeat").hasRole("AGENT")
|
||||||
|
.requestMatchers("/api/v1/agents/*/events").hasRole("AGENT")
|
||||||
|
.requestMatchers("/api/v1/agents/*/commands/*/ack").hasRole("AGENT")
|
||||||
|
|
||||||
|
// Command endpoints — operator+ only
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/v1/agents/*/commands").hasAnyRole("OPERATOR", "ADMIN")
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/v1/agents/groups/*/commands").hasAnyRole("OPERATOR", "ADMIN")
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/v1/agents/commands").hasAnyRole("OPERATOR", "ADMIN")
|
||||||
|
|
||||||
|
// Search endpoints
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT")
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
|
|
||||||
|
// Read-only data endpoints — viewer+
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/v1/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/v1/diagrams/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/v1/agents").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/v1/stats/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
|
|
||||||
|
// Admin endpoints
|
||||||
|
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
|
||||||
|
|
||||||
|
// Everything else requires authentication
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
.exceptionHandling(ex -> ex
|
.exceptionHandling(ex -> ex
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.cameleer3.server.app.security;
|
|||||||
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration properties for security settings.
|
* Configuration properties for security settings.
|
||||||
* Bound from the {@code security.*} namespace in application.yml.
|
* Bound from the {@code security.*} namespace in application.yml.
|
||||||
@@ -16,60 +18,47 @@ public class SecurityProperties {
|
|||||||
private String uiUser;
|
private String uiUser;
|
||||||
private String uiPassword;
|
private String uiPassword;
|
||||||
private String uiOrigin;
|
private String uiOrigin;
|
||||||
|
private String jwtSecret;
|
||||||
|
private Oidc oidc = new Oidc();
|
||||||
|
|
||||||
public long getAccessTokenExpiryMs() {
|
public static class Oidc {
|
||||||
return accessTokenExpiryMs;
|
private boolean enabled = false;
|
||||||
|
private String issuerUri;
|
||||||
|
private String clientId;
|
||||||
|
private String clientSecret;
|
||||||
|
private String rolesClaim = "realm_access.roles";
|
||||||
|
private List<String> defaultRoles = List.of("VIEWER");
|
||||||
|
|
||||||
|
public boolean isEnabled() { return enabled; }
|
||||||
|
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||||
|
public String getIssuerUri() { return issuerUri; }
|
||||||
|
public void setIssuerUri(String issuerUri) { this.issuerUri = issuerUri; }
|
||||||
|
public String getClientId() { return clientId; }
|
||||||
|
public void setClientId(String clientId) { this.clientId = clientId; }
|
||||||
|
public String getClientSecret() { return clientSecret; }
|
||||||
|
public void setClientSecret(String clientSecret) { this.clientSecret = clientSecret; }
|
||||||
|
public String getRolesClaim() { return rolesClaim; }
|
||||||
|
public void setRolesClaim(String rolesClaim) { this.rolesClaim = rolesClaim; }
|
||||||
|
public List<String> getDefaultRoles() { return defaultRoles; }
|
||||||
|
public void setDefaultRoles(List<String> defaultRoles) { this.defaultRoles = defaultRoles; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setAccessTokenExpiryMs(long accessTokenExpiryMs) {
|
public long getAccessTokenExpiryMs() { return accessTokenExpiryMs; }
|
||||||
this.accessTokenExpiryMs = accessTokenExpiryMs;
|
public void setAccessTokenExpiryMs(long accessTokenExpiryMs) { this.accessTokenExpiryMs = accessTokenExpiryMs; }
|
||||||
}
|
public long getRefreshTokenExpiryMs() { return refreshTokenExpiryMs; }
|
||||||
|
public void setRefreshTokenExpiryMs(long refreshTokenExpiryMs) { this.refreshTokenExpiryMs = refreshTokenExpiryMs; }
|
||||||
public long getRefreshTokenExpiryMs() {
|
public String getBootstrapToken() { return bootstrapToken; }
|
||||||
return refreshTokenExpiryMs;
|
public void setBootstrapToken(String bootstrapToken) { this.bootstrapToken = bootstrapToken; }
|
||||||
}
|
public String getBootstrapTokenPrevious() { return bootstrapTokenPrevious; }
|
||||||
|
public void setBootstrapTokenPrevious(String bootstrapTokenPrevious) { this.bootstrapTokenPrevious = bootstrapTokenPrevious; }
|
||||||
public void setRefreshTokenExpiryMs(long refreshTokenExpiryMs) {
|
public String getUiUser() { return uiUser; }
|
||||||
this.refreshTokenExpiryMs = refreshTokenExpiryMs;
|
public void setUiUser(String uiUser) { this.uiUser = uiUser; }
|
||||||
}
|
public String getUiPassword() { return uiPassword; }
|
||||||
|
public void setUiPassword(String uiPassword) { this.uiPassword = uiPassword; }
|
||||||
public String getBootstrapToken() {
|
public String getUiOrigin() { return uiOrigin; }
|
||||||
return bootstrapToken;
|
public void setUiOrigin(String uiOrigin) { this.uiOrigin = uiOrigin; }
|
||||||
}
|
public String getJwtSecret() { return jwtSecret; }
|
||||||
|
public void setJwtSecret(String jwtSecret) { this.jwtSecret = jwtSecret; }
|
||||||
public void setBootstrapToken(String bootstrapToken) {
|
public Oidc getOidc() { return oidc; }
|
||||||
this.bootstrapToken = bootstrapToken;
|
public void setOidc(Oidc oidc) { this.oidc = oidc; }
|
||||||
}
|
|
||||||
|
|
||||||
public String getBootstrapTokenPrevious() {
|
|
||||||
return bootstrapTokenPrevious;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setBootstrapTokenPrevious(String bootstrapTokenPrevious) {
|
|
||||||
this.bootstrapTokenPrevious = bootstrapTokenPrevious;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getUiUser() {
|
|
||||||
return uiUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setUiUser(String uiUser) {
|
|
||||||
this.uiUser = uiUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getUiPassword() {
|
|
||||||
return uiPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setUiPassword(String uiPassword) {
|
|
||||||
this.uiPassword = uiPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getUiOrigin() {
|
|
||||||
return uiOrigin;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setUiOrigin(String uiOrigin) {
|
|
||||||
this.uiOrigin = uiOrigin;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.cameleer3.server.app.security;
|
package com.cameleer3.server.app.security;
|
||||||
|
|
||||||
import com.cameleer3.server.core.security.JwtService;
|
import com.cameleer3.server.core.security.JwtService;
|
||||||
|
import com.cameleer3.server.core.security.JwtService.JwtValidationResult;
|
||||||
|
import com.cameleer3.server.core.security.UserInfo;
|
||||||
|
import com.cameleer3.server.core.security.UserRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -9,14 +12,16 @@ import org.springframework.web.bind.annotation.RequestBody;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication endpoints for the UI.
|
* Authentication endpoints for the UI (local credentials).
|
||||||
* <p>
|
* <p>
|
||||||
* Validates credentials against environment-configured username/password,
|
* Validates credentials against environment-configured username/password,
|
||||||
* then issues JWTs with {@code ui:} prefixed subjects to distinguish
|
* then issues JWTs with {@code ui:} prefixed subjects and ADMIN roles.
|
||||||
* UI users from agent tokens in {@link JwtAuthenticationFilter}.
|
* Upserts the user into the user store on login.
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/auth")
|
@RequestMapping("/api/v1/auth")
|
||||||
@@ -26,10 +31,13 @@ public class UiAuthController {
|
|||||||
|
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final SecurityProperties properties;
|
private final SecurityProperties properties;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
public UiAuthController(JwtService jwtService, SecurityProperties properties) {
|
public UiAuthController(JwtService jwtService, SecurityProperties properties,
|
||||||
|
UserRepository userRepository) {
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
|
this.userRepository = userRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
@@ -50,8 +58,18 @@ public class UiAuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String subject = "ui:" + request.username();
|
String subject = "ui:" + request.username();
|
||||||
String accessToken = jwtService.createAccessToken(subject, "ui");
|
List<String> roles = List.of("ADMIN");
|
||||||
String refreshToken = jwtService.createRefreshToken(subject, "ui");
|
|
||||||
|
// Upsert local user into store
|
||||||
|
try {
|
||||||
|
userRepository.upsert(new UserInfo(
|
||||||
|
subject, "local", "", request.username(), roles, Instant.now()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to upsert local user to store (login continues): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
String accessToken = jwtService.createAccessToken(subject, "ui", roles);
|
||||||
|
String refreshToken = jwtService.createRefreshToken(subject, "ui", roles);
|
||||||
|
|
||||||
log.info("UI user logged in: {}", request.username());
|
log.info("UI user logged in: {}", request.username());
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of(
|
||||||
@@ -63,13 +81,15 @@ public class UiAuthController {
|
|||||||
@PostMapping("/refresh")
|
@PostMapping("/refresh")
|
||||||
public ResponseEntity<?> refresh(@RequestBody RefreshRequest request) {
|
public ResponseEntity<?> refresh(@RequestBody RefreshRequest request) {
|
||||||
try {
|
try {
|
||||||
String subject = jwtService.validateRefreshToken(request.refreshToken());
|
JwtValidationResult result = jwtService.validateRefreshToken(request.refreshToken());
|
||||||
if (!subject.startsWith("ui:")) {
|
if (!result.subject().startsWith("ui:")) {
|
||||||
return ResponseEntity.status(401).body(Map.of("message", "Not a UI token"));
|
return ResponseEntity.status(401).body(Map.of("message", "Not a UI token"));
|
||||||
}
|
}
|
||||||
|
|
||||||
String accessToken = jwtService.createAccessToken(subject, "ui");
|
// Preserve roles from the refresh token
|
||||||
String refreshToken = jwtService.createRefreshToken(subject, "ui");
|
List<String> roles = result.roles();
|
||||||
|
String accessToken = jwtService.createAccessToken(result.subject(), "ui", roles);
|
||||||
|
String refreshToken = jwtService.createRefreshToken(result.subject(), "ui", roles);
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of(
|
||||||
"accessToken", accessToken,
|
"accessToken", accessToken,
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.cameleer3.server.app.storage;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.security.UserInfo;
|
||||||
|
import com.cameleer3.server.core.security.UserRepository;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ClickHouse implementation of {@link UserRepository}.
|
||||||
|
* <p>
|
||||||
|
* Uses ReplacingMergeTree — reads use {@code FINAL} to get the latest version.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class ClickHouseUserRepository implements UserRepository {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public ClickHouseUserRepository(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<UserInfo> findById(String userId) {
|
||||||
|
List<UserInfo> results = jdbc.query(
|
||||||
|
"SELECT user_id, provider, email, display_name, roles, created_at "
|
||||||
|
+ "FROM users FINAL WHERE user_id = ?",
|
||||||
|
this::mapRow,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<UserInfo> findAll() {
|
||||||
|
return jdbc.query(
|
||||||
|
"SELECT user_id, provider, email, display_name, roles, created_at FROM users FINAL ORDER BY user_id",
|
||||||
|
this::mapRow
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void upsert(UserInfo user) {
|
||||||
|
jdbc.update(
|
||||||
|
"INSERT INTO users (user_id, provider, email, display_name, roles, updated_at) VALUES (?, ?, ?, ?, ?, now64(3, 'UTC'))",
|
||||||
|
user.userId(),
|
||||||
|
user.provider(),
|
||||||
|
user.email(),
|
||||||
|
user.displayName(),
|
||||||
|
user.roles().toArray(new String[0])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updateRoles(String userId, List<String> roles) {
|
||||||
|
// ReplacingMergeTree: insert a new row with updated_at to supersede the old one.
|
||||||
|
// Copy existing fields, update roles.
|
||||||
|
jdbc.update(
|
||||||
|
"INSERT INTO users (user_id, provider, email, display_name, roles, created_at, updated_at) "
|
||||||
|
+ "SELECT user_id, provider, email, display_name, ?, created_at, now64(3, 'UTC') "
|
||||||
|
+ "FROM users FINAL WHERE user_id = ?",
|
||||||
|
roles.toArray(new String[0]),
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(String userId) {
|
||||||
|
jdbc.update("DELETE FROM users WHERE user_id = ?", userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserInfo mapRow(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
String[] rolesArray = (String[]) rs.getArray("roles").getArray();
|
||||||
|
return new UserInfo(
|
||||||
|
rs.getString("user_id"),
|
||||||
|
rs.getString("provider"),
|
||||||
|
rs.getString("email"),
|
||||||
|
rs.getString("display_name"),
|
||||||
|
Arrays.asList(rolesArray),
|
||||||
|
rs.getTimestamp("created_at").toInstant()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,14 @@ security:
|
|||||||
ui-user: ${CAMELEER_UI_USER:admin}
|
ui-user: ${CAMELEER_UI_USER:admin}
|
||||||
ui-password: ${CAMELEER_UI_PASSWORD:admin}
|
ui-password: ${CAMELEER_UI_PASSWORD:admin}
|
||||||
ui-origin: ${CAMELEER_UI_ORIGIN:http://localhost:5173}
|
ui-origin: ${CAMELEER_UI_ORIGIN:http://localhost:5173}
|
||||||
|
jwt-secret: ${CAMELEER_JWT_SECRET:}
|
||||||
|
oidc:
|
||||||
|
enabled: ${CAMELEER_OIDC_ENABLED:false}
|
||||||
|
issuer-uri: ${CAMELEER_OIDC_ISSUER:}
|
||||||
|
client-id: ${CAMELEER_OIDC_CLIENT_ID:}
|
||||||
|
client-secret: ${CAMELEER_OIDC_CLIENT_SECRET:}
|
||||||
|
roles-claim: ${CAMELEER_OIDC_ROLES_CLAIM:realm_access.roles}
|
||||||
|
default-roles: ${CAMELEER_OIDC_DEFAULT_ROLES:VIEWER}
|
||||||
|
|
||||||
springdoc:
|
springdoc:
|
||||||
api-docs:
|
api-docs:
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
user_id String,
|
||||||
|
provider LowCardinality(String),
|
||||||
|
email String DEFAULT '',
|
||||||
|
display_name String DEFAULT '',
|
||||||
|
roles Array(LowCardinality(String)),
|
||||||
|
created_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC'),
|
||||||
|
updated_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC')
|
||||||
|
) ENGINE = ReplacingMergeTree(updated_at)
|
||||||
|
ORDER BY (user_id);
|
||||||
@@ -56,7 +56,7 @@ public abstract class AbstractClickHouseIT {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load all schema files in order
|
// Load all schema files in order
|
||||||
String[] schemaFiles = {"01-schema.sql", "02-search-columns.sql"};
|
String[] schemaFiles = {"01-schema.sql", "02-search-columns.sql", "03-users.sql"};
|
||||||
|
|
||||||
try (Connection conn = DriverManager.getConnection(
|
try (Connection conn = DriverManager.getConnection(
|
||||||
CLICKHOUSE.getJdbcUrl(),
|
CLICKHOUSE.getJdbcUrl(),
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import com.cameleer3.server.core.security.JwtService;
|
|||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,8 +52,8 @@ class JwtServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void createRefreshToken_canBeValidatedWithRefreshMethod() {
|
void createRefreshToken_canBeValidatedWithRefreshMethod() {
|
||||||
String token = jwtService.createRefreshToken("agent-2", "group-b");
|
String token = jwtService.createRefreshToken("agent-2", "group-b");
|
||||||
String agentId = jwtService.validateRefreshToken(token);
|
JwtService.JwtValidationResult result = jwtService.validateRefreshToken(token);
|
||||||
assertEquals("agent-2", agentId);
|
assertEquals("agent-2", result.subject());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -70,6 +72,50 @@ class JwtServiceTest {
|
|||||||
"Refresh validation should reject access tokens");
|
"Refresh validation should reject access tokens");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void accessToken_rolesRoundTrip() {
|
||||||
|
List<String> roles = List.of("ADMIN", "OPERATOR");
|
||||||
|
String token = jwtService.createAccessToken("ui:admin", "ui", roles);
|
||||||
|
JwtService.JwtValidationResult result = jwtService.validateAccessToken(token);
|
||||||
|
assertEquals("ui:admin", result.subject());
|
||||||
|
assertEquals("ui", result.group());
|
||||||
|
assertEquals(roles, result.roles());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refreshToken_rolesRoundTrip() {
|
||||||
|
List<String> roles = List.of("AGENT");
|
||||||
|
String token = jwtService.createRefreshToken("agent-1", "default", roles);
|
||||||
|
JwtService.JwtValidationResult result = jwtService.validateRefreshToken(token);
|
||||||
|
assertEquals("agent-1", result.subject());
|
||||||
|
assertEquals("default", result.group());
|
||||||
|
assertEquals(roles, result.roles());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void legacyToken_emptyRoles() {
|
||||||
|
// Backward compat: tokens without explicit roles get empty list
|
||||||
|
String token = jwtService.createAccessToken("agent-1", "default");
|
||||||
|
JwtService.JwtValidationResult result = jwtService.validateAccessToken(token);
|
||||||
|
assertEquals(List.of(), result.roles());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void configuredJwtSecret_producesStableTokens() {
|
||||||
|
SecurityProperties props = new SecurityProperties();
|
||||||
|
props.setAccessTokenExpiryMs(3_600_000);
|
||||||
|
props.setRefreshTokenExpiryMs(604_800_000);
|
||||||
|
props.setJwtSecret("my-test-secret-that-is-at-least-32-bytes");
|
||||||
|
JwtService svc1 = new JwtServiceImpl(props);
|
||||||
|
JwtService svc2 = new JwtServiceImpl(props);
|
||||||
|
|
||||||
|
String token = svc1.createAccessToken("agent-1", "default", List.of("AGENT"));
|
||||||
|
// Token created by svc1 should be validatable by svc2 (same secret)
|
||||||
|
JwtService.JwtValidationResult result = svc2.validateAccessToken(token);
|
||||||
|
assertEquals("agent-1", result.subject());
|
||||||
|
assertEquals(List.of("AGENT"), result.roles());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void validateAndExtractAgentId_rejectsExpiredToken() {
|
void validateAndExtractAgentId_rejectsExpiredToken() {
|
||||||
// Create a service with 0ms expiry to produce already-expired tokens
|
// Create a service with 0ms expiry to produce already-expired tokens
|
||||||
|
|||||||
@@ -1,48 +1,60 @@
|
|||||||
package com.cameleer3.server.core.security;
|
package com.cameleer3.server.core.security;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for creating and validating JSON Web Tokens (JWT).
|
* Service for creating and validating JSON Web Tokens (JWT).
|
||||||
* <p>
|
* <p>
|
||||||
* Access tokens are short-lived (default 1 hour) and used for API authentication.
|
* Access tokens are short-lived (default 1 hour) and used for API authentication.
|
||||||
* Refresh tokens are longer-lived (default 7 days) and used to obtain new access tokens.
|
* Refresh tokens are longer-lived (default 7 days) and used to obtain new access tokens.
|
||||||
|
* Tokens carry a {@code roles} claim for role-based access control.
|
||||||
*/
|
*/
|
||||||
public interface JwtService {
|
public interface JwtService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a signed access JWT with the given agent ID and group.
|
* Validated JWT payload.
|
||||||
*
|
*
|
||||||
* @param agentId the agent identifier (becomes the {@code sub} claim)
|
* @param subject the {@code sub} claim (agent ID or {@code ui:<username>})
|
||||||
* @param group the agent group (becomes the {@code group} claim)
|
* @param group the {@code group} claim
|
||||||
* @return a signed JWT string
|
* @param roles the {@code roles} claim (e.g. {@code ["AGENT"]}, {@code ["ADMIN"]})
|
||||||
*/
|
*/
|
||||||
String createAccessToken(String agentId, String group);
|
record JwtValidationResult(String subject, String group, List<String> roles) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a signed refresh JWT with the given agent ID and group.
|
* Creates a signed access JWT with the given subject, group, and roles.
|
||||||
*
|
|
||||||
* @param agentId the agent identifier (becomes the {@code sub} claim)
|
|
||||||
* @param group the agent group (becomes the {@code group} claim)
|
|
||||||
* @return a signed JWT string
|
|
||||||
*/
|
*/
|
||||||
String createRefreshToken(String agentId, String group);
|
String createAccessToken(String subject, String group, List<String> roles);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates an access token and extracts the agent ID.
|
* Creates a signed refresh JWT with the given subject, group, and roles.
|
||||||
* Rejects expired tokens and tokens that are not of type "access".
|
*/
|
||||||
|
String createRefreshToken(String subject, String group, List<String> roles);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an access token and returns the full validation result.
|
||||||
*
|
*
|
||||||
* @param token the JWT string to validate
|
|
||||||
* @return the agent ID from the {@code sub} claim
|
|
||||||
* @throws InvalidTokenException if the token is invalid, expired, or not an access token
|
* @throws InvalidTokenException if the token is invalid, expired, or not an access token
|
||||||
*/
|
*/
|
||||||
String validateAndExtractAgentId(String token);
|
JwtValidationResult validateAccessToken(String token);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates a refresh token and extracts the agent ID.
|
* Validates a refresh token and returns the full validation result.
|
||||||
* Rejects expired tokens and tokens that are not of type "refresh".
|
|
||||||
*
|
*
|
||||||
* @param token the JWT string to validate
|
|
||||||
* @return the agent ID from the {@code sub} claim
|
|
||||||
* @throws InvalidTokenException if the token is invalid, expired, or not a refresh token
|
* @throws InvalidTokenException if the token is invalid, expired, or not a refresh token
|
||||||
*/
|
*/
|
||||||
String validateRefreshToken(String token);
|
JwtValidationResult validateRefreshToken(String token);
|
||||||
|
|
||||||
|
// --- Backward-compatible defaults (delegate to role-aware methods) ---
|
||||||
|
|
||||||
|
default String createAccessToken(String subject, String group) {
|
||||||
|
return createAccessToken(subject, group, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
default String createRefreshToken(String subject, String group) {
|
||||||
|
return createRefreshToken(subject, group, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
default String validateAndExtractAgentId(String token) {
|
||||||
|
return validateAccessToken(token).subject();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.cameleer3.server.core.security;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a persisted user in the system.
|
||||||
|
*
|
||||||
|
* @param userId unique identifier (e.g. OIDC {@code sub} or {@code ui:<username>})
|
||||||
|
* @param provider authentication provider ({@code "local"}, {@code "oidc:<issuer-host>"})
|
||||||
|
* @param email user email (may be empty)
|
||||||
|
* @param displayName display name (may be empty)
|
||||||
|
* @param roles assigned roles (e.g. {@code ["ADMIN"]}, {@code ["VIEWER"]})
|
||||||
|
* @param createdAt first creation timestamp
|
||||||
|
*/
|
||||||
|
public record UserInfo(
|
||||||
|
String userId,
|
||||||
|
String provider,
|
||||||
|
String email,
|
||||||
|
String displayName,
|
||||||
|
List<String> roles,
|
||||||
|
Instant createdAt
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.cameleer3.server.core.security;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistence interface for user management.
|
||||||
|
*/
|
||||||
|
public interface UserRepository {
|
||||||
|
|
||||||
|
Optional<UserInfo> findById(String userId);
|
||||||
|
|
||||||
|
List<UserInfo> findAll();
|
||||||
|
|
||||||
|
void upsert(UserInfo user);
|
||||||
|
|
||||||
|
void updateRoles(String userId, List<String> roles);
|
||||||
|
|
||||||
|
void delete(String userId);
|
||||||
|
}
|
||||||
10
clickhouse/init/03-users.sql
Normal file
10
clickhouse/init/03-users.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
user_id String,
|
||||||
|
provider LowCardinality(String),
|
||||||
|
email String DEFAULT '',
|
||||||
|
display_name String DEFAULT '',
|
||||||
|
roles Array(LowCardinality(String)),
|
||||||
|
created_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC'),
|
||||||
|
updated_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC')
|
||||||
|
) ENGINE = ReplacingMergeTree(updated_at)
|
||||||
|
ORDER BY (user_id);
|
||||||
Reference in New Issue
Block a user