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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import jakarta.servlet.http.HttpServletResponse;
import vip.mate.kbopen.auth.KbOpenApiAuthFilter;

/**
* Spring Security 配置
Expand All @@ -28,6 +29,7 @@
public class SecurityConfig {

private final JwtAuthFilter jwtAuthFilter;
private final KbOpenApiAuthFilter kbOpenApiAuthFilter;

/**
* SpringDoc Swagger UI / OpenAPI document paths. These serve the full REST
Expand Down Expand Up @@ -90,6 +92,9 @@ public SecurityFilterChain filterChain(
"/api/v1/setup/**",
"/api/v1/channels/webhook/**",
"/api/v1/channels/webchat/**",
// KB Open API: authenticated by KbOpenApiAuthFilter (API key),
// not JWT — must be permitAll so the filter is the sole gatekeeper (R1).
"/api/v1/open/kb/**",
"/api/v1/talk/ws",
// Desktop local-tool tunnel — the handshake interceptor
// authenticates the ?token= query param itself, so the
Expand Down Expand Up @@ -120,6 +125,7 @@ public SecurityFilterChain filterChain(
response.getWriter().write("{\"code\":401,\"msg\":\"Token expired or invalid\",\"data\":null}");
})
)
.addFilterBefore(kbOpenApiAuthFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

return http.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import vip.mate.kbopen.auth.KbScopeInterceptor;

/**
* Web MVC 配置(跨域、拦截器等)
Expand All @@ -20,6 +21,7 @@
public class WebMvcConfig implements WebMvcConfigurer {

private final WorkspaceAccessInterceptor workspaceAccessInterceptor;
private final KbScopeInterceptor kbScopeInterceptor;

/** CORS allowed origins, comma-separated. Default "*" for dev, restrict in production. */
@Value("${mateclaw.cors.allowed-origins:*}")
Expand All @@ -29,6 +31,10 @@ public class WebMvcConfig implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(workspaceAccessInterceptor)
.addPathPatterns("/api/**");
// KB Open API scope+ownership checks (A1). Runs after KbOpenApiAuthFilter
// injected the KbApiKeyContext into the request attributes.
registry.addInterceptor(kbScopeInterceptor)
.addPathPatterns("/api/v1/open/kb/**");
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package vip.mate.kbopen.auth;

import java.util.Set;

/**
* Authentication context injected by {@code KbOpenApiAuthFilter} into each
* request's attributes. Carries the resolved API key identity (keyId,
* workspace, bound KBs, scopes) so that Controller-layer authorization
* (via {@code @RequireKbScope}) can check access without re-querying the DB.
*
* <p>This is intentionally <strong>not</strong> a Spring Security
* {@code Authentication} — KB API Keys do not represent a user identity and
* must not inherit user-level permissions. The filter writes to a request
* attribute instead of the {@code SecurityContextHolder}.
*/
public record KbApiKeyContext(
Long keyId,
Long workspaceId,
Set<Long> kbIds,
Set<String> scopes,
int rateLimitPerMin
) {

/** Request attribute key under which the context is stored. */
public static final String ATTR = "kbOpenApiKeyContext";

/** Check whether the key grants the given scope (or the wildcard). */
public boolean hasScope(String scope) {
return scopes.contains("kb:*") || scopes.contains(scope);
}

/**
* Check whether the key is authorized to access the given KB. An empty
* {@code kbIds} set means zero access (R3).
*/
public boolean canAccessKb(Long kbId) {
return kbId != null && kbIds.contains(kbId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package vip.mate.kbopen.auth;

import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* Per-key sliding-window rate limiter for KB Open API (R2).
*
* <p>Follows the same pattern as {@code TriggerRateLimiter}: each key keeps a
* 60-second window of request timestamps; a request is admitted iff fewer
* than {@code rateLimitPerMin} entries already live in the window. Local to
* this node — for multi-node deployments the cap is per-node, not global.
* v0 accepts this trade because the alternative (DB-backed counters) costs a
* round-trip on every request.
*/
@Component
public class KbApiKeyRateLimiter {

private final Map<Long, Deque<Instant>> windows = new ConcurrentHashMap<>();
private final Duration windowSize = Duration.ofMinutes(1);

/**
* Try to admit a request for {@code keyId} at {@code now}. Returns
* {@code true} when the request fits under {@code limitPerMin};
* {@code false} when the window is full (caller should return 429).
*/
public boolean tryAcquire(long keyId, int limitPerMin, Instant now) {
if (limitPerMin <= 0) return true;
Deque<Instant> window = windows.computeIfAbsent(keyId, k -> new ArrayDeque<>());
Instant cutoff = now.minus(windowSize);
synchronized (window) {
while (!window.isEmpty() && !window.peekFirst().isAfter(cutoff)) {
window.pollFirst();
}
if (window.size() >= limitPerMin) return false;
window.addLast(now);
return true;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
package vip.mate.kbopen.auth;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.exception.MateClawException;
import vip.mate.kbopen.auth.model.KbApiKeyBindingEntity;
import vip.mate.kbopen.auth.model.KbApiKeyEntity;
import vip.mate.kbopen.auth.repository.KbApiKeyBindingMapper;
import vip.mate.kbopen.auth.repository.KbApiKeyMapper;

import java.time.LocalDateTime;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

/**
* Lifecycle management for KB Open API Keys: mint, authenticate, revoke,
* and manage KB bindings.
*
* <p>Plaintext is shown exactly once at creation time — the DB stores only
* the SHA-256 hash (via {@link TokenHashUtil}, the shared kernel).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class KbApiKeyService {

public static final String KEY_PREFIX = "mck_";
private static final int KEY_ENTROPY_BYTES = 32;
private static final int PREFIX_DISPLAY_LEN = 4;
private static final long LAST_USED_DEBOUNCE_SECONDS = 60;
private static final int DEFAULT_RATE_LIMIT = 60;

private final KbApiKeyMapper keyMapper;
private final KbApiKeyBindingMapper bindingMapper;
private final TokenHashUtil tokenHashUtil;

// ── Mint ──────────────────────────────────────────────────────────────

/**
* Mint a new API Key bound to the given KBs.
*
* @param workspaceId owning workspace
* @param createdBy user id of the creator
* @param name human-readable label
* @param scopes comma-separated scopes, null/blank = "kb:*"
* @param kbIds KBs this key can access — must be non-empty (R3)
* @param expiresAt optional expiry, null = never
* @return the created entity + one-shot plaintext
*/
@Transactional
public CreatedKey create(Long workspaceId, Long createdBy, String name,
String scopes, Set<Long> kbIds, LocalDateTime expiresAt) {
if (kbIds == null || kbIds.isEmpty()) {
// R3: empty binding = zero access, which is useless for an external key.
throw new MateClawException(400, "At least one knowledge base must be bound to the API key");
}
String plaintext = tokenHashUtil.generate(KEY_PREFIX, KEY_ENTROPY_BYTES);
String hash = tokenHashUtil.hash(plaintext);
String displayPrefix = plaintext.substring(0, Math.min(PREFIX_DISPLAY_LEN + KEY_PREFIX.length(), plaintext.length()));

KbApiKeyEntity entity = new KbApiKeyEntity();
entity.setName(name);
entity.setTokenHash(hash);
entity.setPrefix(displayPrefix);
entity.setWorkspaceId(workspaceId);
entity.setCreatedBy(createdBy);
entity.setScopes(scopes != null && !scopes.isBlank() ? scopes : "kb:*");
entity.setEnabled(true);
entity.setExpiresAt(expiresAt);
entity.setRateLimitPerMin(DEFAULT_RATE_LIMIT);
entity.setDeleted(0);
keyMapper.insert(entity);

for (Long kbId : kbIds) {
KbApiKeyBindingEntity binding = new KbApiKeyBindingEntity();
binding.setApiKeyId(entity.getId());
binding.setKbId(kbId);
bindingMapper.insert(binding);
}

log.info("[KbOpenApi] Created key id={} workspaceId={} name={} boundKbs={}",
entity.getId(), workspaceId, name, kbIds.size());
return new CreatedKey(entity.getId(), plaintext, entity);
}

// ── Authenticate ──────────────────────────────────────────────────────

/**
* Auth-filter hot path: find an enabled, unexpired key whose hash matches
* the SHA-256 of {@code plaintext}, and load its bound KBs + scopes.
*
* @return empty for null/blank, wrong prefix, hash miss, disabled, or expired
*/
public Optional<AuthResult> authenticate(String plaintext) {
if (plaintext == null || plaintext.isBlank() || !plaintext.startsWith(KEY_PREFIX)) {
return Optional.empty();
}
String hash = tokenHashUtil.hash(plaintext);
KbApiKeyEntity entity = keyMapper.selectOne(
new LambdaQueryWrapper<KbApiKeyEntity>()
.eq(KbApiKeyEntity::getTokenHash, hash)
.eq(KbApiKeyEntity::getEnabled, true)
.eq(KbApiKeyEntity::getDeleted, 0)
.last("LIMIT 1"));
if (entity == null) {
return Optional.empty();
}
if (entity.getExpiresAt() != null && entity.getExpiresAt().isBefore(LocalDateTime.now())) {
return Optional.empty();
}

Set<Long> kbIds = loadBoundKbIds(entity.getId());
Set<String> scopes = parseScopes(entity.getScopes());
int rateLimit = entity.getRateLimitPerMin() != null ? entity.getRateLimitPerMin() : DEFAULT_RATE_LIMIT;

KbApiKeyContext context = new KbApiKeyContext(
entity.getId(), entity.getWorkspaceId(), kbIds, scopes, rateLimit);
return Optional.of(new AuthResult(context, entity.getLastUsedAt()));
}

/**
* Resolve a context from a known entity id (used by admin endpoints).
*/
public KbApiKeyEntity getById(Long id) {
return keyMapper.selectById(id);
}

// ── List / Revoke / Update ────────────────────────────────────────────

public List<KbApiKeyEntity> listByWorkspace(Long workspaceId) {
return keyMapper.selectList(
new LambdaQueryWrapper<KbApiKeyEntity>()
.eq(KbApiKeyEntity::getWorkspaceId, workspaceId)
.eq(KbApiKeyEntity::getDeleted, 0)
.orderByDesc(KbApiKeyEntity::getCreateTime));
}

public Set<Long> loadBoundKbIds(Long apiKeyId) {
List<KbApiKeyBindingEntity> bindings = bindingMapper.selectList(
new LambdaQueryWrapper<KbApiKeyBindingEntity>()
.eq(KbApiKeyBindingEntity::getApiKeyId, apiKeyId));
return bindings.stream()
.map(KbApiKeyBindingEntity::getKbId)
.collect(Collectors.toCollection(LinkedHashSet::new));
}

@Transactional
public void updateBindings(Long apiKeyId, Set<Long> newKbIds) {
if (newKbIds == null || newKbIds.isEmpty()) {
// R3: cannot reduce to zero access.
throw new MateClawException(400, "At least one knowledge base must be bound to the API key");
}
bindingMapper.delete(new LambdaQueryWrapper<KbApiKeyBindingEntity>()
.eq(KbApiKeyBindingEntity::getApiKeyId, apiKeyId));
for (Long kbId : newKbIds) {
KbApiKeyBindingEntity binding = new KbApiKeyBindingEntity();
binding.setApiKeyId(apiKeyId);
binding.setKbId(kbId);
bindingMapper.insert(binding);
}
}

@Transactional
public void update(Long apiKeyId, Long workspaceId, String name, String scopes,
Set<Long> kbIds, LocalDateTime expiresAt) {
KbApiKeyEntity existing = keyMapper.selectById(apiKeyId);
if (existing == null || (existing.getDeleted() != null && existing.getDeleted() == 1)
|| !workspaceId.equals(existing.getWorkspaceId())) {
throw new MateClawException(404, "API key not found: " + apiKeyId);
}
if (name != null) existing.setName(name);
if (scopes != null) existing.setScopes(scopes);
if (expiresAt != null) existing.setExpiresAt(expiresAt);
keyMapper.updateById(existing);
if (kbIds != null) {
updateBindings(apiKeyId, kbIds);
}
}

@Transactional
public void revoke(Long apiKeyId, Long workspaceId) {
KbApiKeyEntity existing = keyMapper.selectById(apiKeyId);
if (existing == null || (existing.getDeleted() != null && existing.getDeleted() == 1)
|| !workspaceId.equals(existing.getWorkspaceId())) {
throw new MateClawException(404, "API key not found: " + apiKeyId);
}
existing.setEnabled(false);
existing.setDeleted(1);
keyMapper.updateById(existing);
log.info("[KbOpenApi] Revoked key id={} workspaceId={}", apiKeyId, workspaceId);
}

// ── Usage tracking ────────────────────────────────────────────────────

/**
* Record last-used timestamp, debounced to once per minute per key
* (same pattern as PAT). {@code previousLastUsedAt} is the entity's
* current lastUsedAt value, used to decide whether enough time has passed.
*/
public void recordUse(Long apiKeyId, LocalDateTime previousLastUsedAt) {
if (apiKeyId == null) return;
if (!shouldRecordUse(previousLastUsedAt)) return;
try {
KbApiKeyEntity update = new KbApiKeyEntity();
update.setId(apiKeyId);
update.setLastUsedAt(LocalDateTime.now());
keyMapper.updateById(update);
} catch (Exception e) {
log.debug("[KbOpenApi] last_used_at write failed for key {}: {}", apiKeyId, e.getMessage());
}
}

static boolean shouldRecordUse(LocalDateTime previousLastUsedAt) {
if (previousLastUsedAt == null) return true;
return !previousLastUsedAt.plusSeconds(LAST_USED_DEBOUNCE_SECONDS).isAfter(LocalDateTime.now());
}

// ── Helpers ───────────────────────────────────────────────────────────

private Set<String> parseScopes(String scopes) {
if (scopes == null || scopes.isBlank()) {
return Set.of("kb:*");
}
return Set.of(scopes.split(",")).stream()
.map(String::trim)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}

/** Return value of {@link #create}. */
public record CreatedKey(Long id, String plaintext, KbApiKeyEntity entity) {}

/** Return value of {@link #authenticate}: context + lastUsedAt for debounce. */
public record AuthResult(KbApiKeyContext context, LocalDateTime lastUsedAt) {}
}
Loading