mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
fix(auth): fail closed invalid cli bearer tokens
Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
parent
4776550391
commit
1d7f78aece
5 changed files with 353 additions and 30 deletions
|
|
@ -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 的绑定。
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue