fix(auth): fail closed invalid cli bearer tokens

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-06-12 11:50:23 +08:00
parent 4776550391
commit 1d7f78aece
5 changed files with 353 additions and 30 deletions

View file

@ -377,6 +377,7 @@ API Token 仍保留但定位从“CLI 唯一认证方式”调整为“平台
- 用途:自动化脚本、兼容层调用、手工 Token 管理、后续系统集成
- 存储:只存 SHA-256 哈希,明文只展示一次
- 校验:从 `Authorization: Bearer <token>` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态
- 失败闭合:公共读接口只有在缺少 `Authorization` 头时才按匿名访问处理;只要出现 Bearer 凭证,空值、格式错误、未知、过期、已吊销、用户缺失或用户禁用均返回 401不能回退为匿名访问
- 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage`
> **一期作用域说明(非最小权限)**:一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER则该用户的任何 Token只要包含 `skill:publish` scope都可以向该 namespace 发布技能。这是有意的一期简化,不满足最小权限原则。后续版本计划引入 namespace 级别的 Token 作用域限定(如 `namespace:ai-team:skill:publish`),或通过 `api_token_scope` 子表实现 Token 与 namespace 的绑定。

View file

@ -1,15 +1,27 @@
package com.iflytek.skillhub.controller.cli;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.entity.ApiToken;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.ratelimit.RateLimit;
import com.iflytek.skillhub.service.cli.CliSkillAppService;
import jakarta.servlet.http.HttpServletRequest;
import java.io.ByteArrayInputStream;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
@ -19,11 +31,17 @@ import org.springframework.web.multipart.MultipartFile;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@ -35,7 +53,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@ActiveProfiles("test")
class CliSkillControllerTest {
@Autowired MockMvc mockMvc;
@Autowired NamespaceMemberRepository namespaceMemberRepository;
@MockBean CliSkillAppService cliSkillAppService;
@MockBean ApiTokenService apiTokenService;
@MockBean UserAccountRepository userAccountRepository;
@MockBean UserRoleBindingRepository userRoleBindingRepository;
@Test
void downloadRoutesUseDownloadRateLimit() throws Exception {
@ -73,6 +95,47 @@ class CliSkillControllerTest {
.andExpect(jsonPath("$.data.items[0].latestVersion").value("1.2.0"));
}
@Test
void searchRejectsInvalidBearerBeforeAnonymousAccess() throws Exception {
givenInvalidBearerToken();
given(cliSkillAppService.search("pdf", 20, null, null)).willReturn(
new CliSkillAppService.CliSearchResult(List.of(), 0, 20)
);
mockMvc.perform(get("/api/cli/v1/skills/search")
.param("q", "pdf")
.param("limit", "20")
.header(HttpHeaders.AUTHORIZATION, "Bearer unknown-token"))
.andExpect(status().isUnauthorized());
verifyNoInteractions(cliSkillAppService);
}
@Test
void searchWithValidBearerProjectsIdentityAndNamespaceRoles() throws Exception {
ApiToken token = new ApiToken("user-cli-token", "cli", "sk_test", "hash", "[]");
UserAccount user = new UserAccount("user-cli-token", "CLI User", "cli@example.com", "");
Map<Long, NamespaceRole> nsRoles = Map.of(9L, NamespaceRole.MEMBER);
given(apiTokenService.validateToken("raw-token")).willReturn(Optional.of(token));
given(userAccountRepository.findById("user-cli-token")).willReturn(Optional.of(user));
given(userRoleBindingRepository.findByUserId("user-cli-token")).willReturn(List.of());
namespaceMemberRepository.save(new NamespaceMember(9L, "user-cli-token", NamespaceRole.MEMBER));
given(cliSkillAppService.search("private", 20, "user-cli-token", nsRoles)).willReturn(
new CliSkillAppService.CliSearchResult(List.of(), 0, 20)
);
mockMvc.perform(get("/api/cli/v1/skills/search")
.param("q", "private")
.param("limit", "20")
.header(HttpHeaders.AUTHORIZATION, "Bearer raw-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items").isArray());
verify(cliSkillAppService).search("private", 20, "user-cli-token", nsRoles);
verify(apiTokenService).touchLastUsed(token);
}
@Test
void resolveReturnsCliResolveResponse() throws Exception {
given(cliSkillAppService.resolve("global", "demo", null, null, null)).willReturn(
@ -91,6 +154,47 @@ class CliSkillControllerTest {
.andExpect(jsonPath("$.data.fingerprint").value("abc123"));
}
@Test
void resolveRejectsInvalidBearerBeforeAnonymousAccess() throws Exception {
givenInvalidBearerToken();
given(cliSkillAppService.resolve("global", "demo", null, null, null)).willReturn(
new com.iflytek.skillhub.dto.cli.CliResolveResponse(
"global", "demo", "2.0.0", 42L, "abc123",
"/api/v1/skills/global/demo/versions/2.0.0/download"
)
);
mockMvc.perform(get("/api/cli/v1/skills/global/demo/resolve")
.header(HttpHeaders.AUTHORIZATION, "Bearer unknown-token"))
.andExpect(status().isUnauthorized());
verify(cliSkillAppService, never()).resolve(any(), any(), any(), any(), any());
}
@Test
void downloadLatestRejectsInvalidBearerBeforeAnonymousAccess() throws Exception {
givenInvalidBearerToken();
given(cliSkillAppService.downloadLatest(any(), any(), any())).willReturn(downloadResponse());
mockMvc.perform(get("/api/cli/v1/skills/global/demo/download")
.header(HttpHeaders.AUTHORIZATION, "Bearer unknown-token"))
.andExpect(status().isUnauthorized());
verify(cliSkillAppService, never()).downloadLatest(any(), any(), any());
}
@Test
void downloadVersionRejectsInvalidBearerBeforeAnonymousAccess() throws Exception {
givenInvalidBearerToken();
given(cliSkillAppService.downloadVersion(any(), any(), any(), any())).willReturn(downloadResponse());
mockMvc.perform(get("/api/cli/v1/skills/global/demo/versions/1.0.0/download")
.header(HttpHeaders.AUTHORIZATION, "Bearer unknown-token"))
.andExpect(status().isUnauthorized());
verify(cliSkillAppService, never()).downloadVersion(any(), any(), any(), any());
}
@Test
void deleteRequiresAuthentication() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
@ -131,4 +235,12 @@ class CliSkillControllerTest {
assertEquals(120, rateLimit.authenticated());
assertEquals(30, rateLimit.anonymous());
}
private static ResponseEntity<InputStreamResource> downloadResponse() {
return ResponseEntity.ok(new InputStreamResource(new ByteArrayInputStream("zip".getBytes())));
}
private void givenInvalidBearerToken() {
given(apiTokenService.validateToken("unknown-token")).willReturn(Optional.empty());
}
}

View file

@ -10,9 +10,12 @@ import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@ -37,49 +40,74 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter {
private final UserAccountRepository userRepo;
private final UserRoleBindingRepository roleBindingRepo;
private final ApiTokenScopeService apiTokenScopeService;
private final AuthenticationEntryPoint authenticationEntryPoint;
@Autowired
public ApiTokenAuthenticationFilter(ApiTokenService apiTokenService,
UserAccountRepository userRepo,
UserRoleBindingRepository roleBindingRepo,
ApiTokenScopeService apiTokenScopeService) {
ApiTokenScopeService apiTokenScopeService,
AuthenticationEntryPoint authenticationEntryPoint) {
this.apiTokenService = apiTokenService;
this.userRepo = userRepo;
this.roleBindingRepo = roleBindingRepo;
this.apiTokenScopeService = apiTokenScopeService;
this.authenticationEntryPoint = authenticationEntryPoint;
}
ApiTokenAuthenticationFilter(ApiTokenService apiTokenService,
UserAccountRepository userRepo,
UserRoleBindingRepository roleBindingRepo,
ApiTokenScopeService apiTokenScopeService) {
this(apiTokenService, userRepo, roleBindingRepo, apiTokenScopeService,
(request, response, authException) ->
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()));
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader(AUTH_HEADER);
if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
String rawToken = authHeader.substring(BEARER_PREFIX.length());
apiTokenService.validateToken(rawToken).ifPresent(token -> {
userRepo.findById(token.getUserId()).ifPresent(user -> {
if (!user.isActive()) {
return;
}
Set<String> roles = roleBindingRepo.findByUserId(user.getId()).stream()
.map(rb -> rb.getRole().getCode())
.collect(Collectors.toSet());
roles = PlatformRoleDefaults.withDefaultUserRole(roles);
Set<String> scopes = apiTokenScopeService.parseScopes(token.getScopeJson());
PlatformPrincipal principal = new PlatformPrincipal(
user.getId(), user.getDisplayName(), user.getEmail(),
user.getAvatarUrl(), "api_token", roles
);
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.addAll(roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList());
authorities.addAll(scopes.stream()
.map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
.toList());
var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
apiTokenService.touchLastUsed(token);
});
});
if (authHeader != null && isBearerAuthorization(authHeader)) {
String rawToken = extractBearerToken(authHeader);
if (rawToken == null) {
rejectBearer(request, response);
return;
}
var token = apiTokenService.validateToken(rawToken);
if (token.isEmpty()) {
rejectBearer(request, response);
return;
}
ApiToken apiToken = token.get();
var user = userRepo.findById(apiToken.getUserId());
if (user.isEmpty() || !user.get().isActive()) {
rejectBearer(request, response);
return;
}
UserAccount userAccount = user.get();
Set<String> roles = roleBindingRepo.findByUserId(userAccount.getId()).stream()
.map(rb -> rb.getRole().getCode())
.collect(Collectors.toSet());
roles = PlatformRoleDefaults.withDefaultUserRole(roles);
Set<String> scopes = apiTokenScopeService.parseScopes(apiToken.getScopeJson());
PlatformPrincipal principal = new PlatformPrincipal(
userAccount.getId(), userAccount.getDisplayName(), userAccount.getEmail(),
userAccount.getAvatarUrl(), "api_token", roles
);
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.addAll(roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList());
authorities.addAll(scopes.stream()
.map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
.toList());
var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
apiTokenService.touchLastUsed(apiToken);
}
filterChain.doFilter(request, response);
}
@ -91,4 +119,30 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter {
|| path.startsWith("/api/web/")
|| path.startsWith("/api/cli/"));
}
private boolean isBearerAuthorization(String authHeader) {
if (!authHeader.regionMatches(true, 0, "Bearer", 0, "Bearer".length())) {
return false;
}
return authHeader.length() == "Bearer".length()
|| Character.isWhitespace(authHeader.charAt("Bearer".length()));
}
private String extractBearerToken(String authHeader) {
if (authHeader.length() <= BEARER_PREFIX.length() - 1
|| authHeader.charAt(BEARER_PREFIX.length() - 1) != ' ') {
return null;
}
String rawToken = authHeader.substring(BEARER_PREFIX.length()).trim();
return rawToken.isEmpty() ? null : rawToken;
}
private void rejectBearer(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
SecurityContextHolder.clearContext();
authenticationEntryPoint.commence(
request,
response,
new BadCredentialsException("Invalid bearer token")
);
}
}

View file

@ -17,11 +17,13 @@ import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@ -89,12 +91,117 @@ class ApiTokenAuthenticationFilterTest {
request.setRequestURI("/api/v1/publish");
request.addHeader("Authorization", "Bearer raw-token");
filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain());
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertNull(SecurityContextHolder.getContext().getAuthentication());
assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
assertNull(chain.getRequest());
verify(apiTokenService, never()).touchLastUsed(token);
}
@Test
void shouldRejectUnknownBearerTokenOnCliReadRoutes() throws Exception {
when(apiTokenService.validateToken("unknown-token")).thenReturn(Optional.empty());
for (String route : cliReadRoutes()) {
SecurityContextHolder.clearContext();
MockHttpServletRequest request = new MockHttpServletRequest("GET", route);
request.addHeader("Authorization", "Bearer unknown-token");
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus(), route);
assertNull(SecurityContextHolder.getContext().getAuthentication(), route);
assertNull(chain.getRequest(), route);
}
}
@Test
void shouldRejectBearerTokenWhenUserIsMissing() throws Exception {
ApiToken token = new ApiToken("missing-user", "cli", "sk_test", "hash", "[]");
when(apiTokenService.validateToken("raw-token")).thenReturn(Optional.of(token));
when(userAccountRepository.findById("missing-user")).thenReturn(Optional.empty());
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search");
request.addHeader("Authorization", "Bearer raw-token");
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
assertNull(SecurityContextHolder.getContext().getAuthentication());
assertNull(chain.getRequest());
verify(apiTokenService, never()).touchLastUsed(token);
}
@Test
void shouldRejectEmptyBearerTokenWithoutValidatingIt() throws Exception {
when(apiTokenService.validateToken("")).thenReturn(Optional.empty());
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search");
request.addHeader("Authorization", "Bearer ");
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
assertNull(SecurityContextHolder.getContext().getAuthentication());
assertNull(chain.getRequest());
verify(apiTokenService, never()).validateToken(any());
}
@Test
void shouldRejectMalformedBearerHeaderWithoutValidatingIt() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search");
request.addHeader("Authorization", "Bearer");
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
assertNull(SecurityContextHolder.getContext().getAuthentication());
assertNull(chain.getRequest());
verify(apiTokenService, never()).validateToken(any());
}
@Test
void shouldAllowAnonymousCliReadsWhenAuthorizationHeaderIsAbsent() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search");
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertEquals(MockHttpServletResponse.SC_OK, response.getStatus());
assertNull(SecurityContextHolder.getContext().getAuthentication());
assertNotNull(chain.getRequest());
verify(apiTokenService, never()).validateToken(any());
}
@Test
void shouldIgnoreNonBearerAuthorizationHeader() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search");
request.addHeader("Authorization", "Basic abc123");
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertEquals(MockHttpServletResponse.SC_OK, response.getStatus());
assertNull(SecurityContextHolder.getContext().getAuthentication());
assertNotNull(chain.getRequest());
verify(apiTokenService, never()).validateToken(any());
}
@Test
void shouldAuthenticateBearerTokensForApiWebRequests() throws Exception {
ApiToken token = new ApiToken("user-3", "cli", "sk_test", "hash", "[\"skill:publish\"]");
@ -113,4 +220,13 @@ class ApiTokenAuthenticationFilterTest {
assertNotNull(SecurityContextHolder.getContext().getAuthentication());
verify(apiTokenService).touchLastUsed(token);
}
private static List<String> cliReadRoutes() {
return Stream.of(
"/api/cli/v1/skills/search",
"/api/cli/v1/skills/global/demo/resolve",
"/api/cli/v1/skills/global/demo/download",
"/api/cli/v1/skills/global/demo/versions/1.0.0/download"
).toList();
}
}

View file

@ -10,9 +10,14 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DataIntegrityViolationException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.HexFormat;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
@ -124,4 +129,39 @@ class ApiTokenServiceTest {
.isInstanceOf(DomainBadRequestException.class)
.hasMessageContaining("error.token.name.duplicate");
}
@Test
void validateToken_returnsEmptyForUnknownToken() {
when(tokenRepo.findByTokenHash(sha256("missing-token"))).thenReturn(Optional.empty());
assertThat(service.validateToken("missing-token")).isEmpty();
}
@Test
void validateToken_returnsEmptyForExpiredToken() {
ApiToken token = new ApiToken("user-1", "CLI", "sk_test", sha256("expired-token"), "[]");
token.setExpiresAt(Instant.parse("2026-03-17T23:59:59Z"));
when(tokenRepo.findByTokenHash(sha256("expired-token"))).thenReturn(Optional.of(token));
assertThat(service.validateToken("expired-token")).isEmpty();
}
@Test
void validateToken_returnsEmptyForRevokedToken() {
ApiToken token = new ApiToken("user-1", "CLI", "sk_test", sha256("revoked-token"), "[]");
token.setRevokedAt(Instant.parse("2026-03-17T23:59:59Z"));
when(tokenRepo.findByTokenHash(sha256("revoked-token"))).thenReturn(Optional.of(token));
assertThat(service.validateToken("revoked-token")).isEmpty();
}
private static String sha256(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
}