From fc9bdffc7d3e370f7e61d7c1556bd912421d6052 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:38:18 +0800 Subject: [PATCH] fix(token): validate name length and uniqueness --- .../skillhub/dto/TokenCreateRequest.java | 2 + .../migration/V8__token_name_constraints.sql | 6 ++ .../src/main/resources/messages.properties | 2 + .../src/main/resources/messages_zh.properties | 2 + .../controller/TokenControllerTest.java | 27 ++++++++ .../skillhub/auth/entity/ApiToken.java | 2 +- .../auth/repository/ApiTokenRepository.java | 1 + .../skillhub/auth/token/ApiTokenService.java | 33 +++++++++- .../auth/token/ApiTokenServiceTest.java | 65 +++++++++++++++++++ .../features/token/create-token-dialog.tsx | 52 +++++++++++++-- web/src/features/token/token-list.tsx | 2 +- web/src/i18n/locales/en.json | 3 + web/src/i18n/locales/zh.json | 3 + 13 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V8__token_name_constraints.sql create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java index 63eadf18..7b821bf4 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java @@ -1,11 +1,13 @@ package com.iflytek.skillhub.dto; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import java.util.List; public record TokenCreateRequest( @NotBlank(message = "{validation.token.name.notBlank}") + @Size(max = 64, message = "{validation.token.name.size}") String name, List scopes ) {} diff --git a/server/skillhub-app/src/main/resources/db/migration/V8__token_name_constraints.sql b/server/skillhub-app/src/main/resources/db/migration/V8__token_name_constraints.sql new file mode 100644 index 00000000..2038bc4c --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V8__token_name_constraints.sql @@ -0,0 +1,6 @@ +ALTER TABLE api_token + ALTER COLUMN name TYPE VARCHAR(64); + +CREATE UNIQUE INDEX uk_api_token_user_active_name + ON api_token (user_id, LOWER(name)) + WHERE revoked_at IS NULL; diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 329db1c0..b6950d12 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -15,6 +15,8 @@ validation.namespace.description.size=Description must not exceed 512 characters validation.member.userId.notNull=User ID is required validation.member.role.notNull=Role is required validation.token.name.notBlank=Token name cannot be blank +validation.token.name.size=Token name must be at most 64 characters +error.token.name.duplicate=You already have a token with this name error.auth.required=Authentication required error.auth.local.username.exists=Username already exists diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 6d839aea..b72d88e0 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -15,6 +15,8 @@ validation.namespace.description.size=描述长度不能超过 512 个字符 validation.member.userId.notNull=用户 ID 不能为空 validation.member.role.notNull=角色不能为空 validation.token.name.notBlank=Token 名称不能为空 +validation.token.name.size=Token 名称最多 64 个字符 +error.token.name.duplicate=你已经有同名 Token error.auth.required=需要先登录 error.auth.local.username.exists=用户名已存在 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java index 52b98554..ebf5a512 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.TestRedisConfig; import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -19,10 +20,14 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.Set; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; +import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -61,4 +66,26 @@ class TokenControllerTest { verify(apiTokenService).revokeToken(7L, "user-42"); } + + @Test + void create_rejectsNamesLongerThan64Characters() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "tester", "tester@example.com", "", "github", Set.of("USER") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + given(apiTokenService.createToken(anyString(), anyString(), anyString())) + .willThrow(new DomainBadRequestException("validation.token.name.size")); + + mockMvc.perform(post("/api/v1/tokens") + .with(authentication(auth)) + .with(csrf()) + .contentType("application/json") + .content(""" + {"name":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.msg").value("Token 名称最多 64 个字符")); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java index 8fb654d2..ae1213d3 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java @@ -21,7 +21,7 @@ public class ApiToken { @Column(name = "user_id", nullable = false) private String userId; - @Column(nullable = false, length = 128) + @Column(nullable = false, length = 64) private String name; @Column(name = "token_prefix", nullable = false, length = 16) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java index cc77c92a..368b0dd6 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java @@ -11,4 +11,5 @@ public interface ApiTokenRepository extends JpaRepository { Optional findByTokenHash(String tokenHash); List findByUserId(String userId); List findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId); + boolean existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase(String userId, String name); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java index f51b60a0..f546e148 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java @@ -2,6 +2,8 @@ package com.iflytek.skillhub.auth.token; import com.iflytek.skillhub.auth.entity.ApiToken; import com.iflytek.skillhub.auth.repository.ApiTokenRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,6 +22,7 @@ public class ApiTokenService { private static final String TOKEN_PREFIX = "sk_"; private static final int TOKEN_BYTES = 32; + private static final int MAX_NAME_LENGTH = 64; private final SecureRandom secureRandom = new SecureRandom(); private final ApiTokenRepository tokenRepo; @@ -31,14 +34,21 @@ public class ApiTokenService { @Transactional public TokenCreateResult createToken(String userId, String name, String scopeJson) { + String normalizedName = normalizeName(name); + validateTokenName(userId, normalizedName); + byte[] randomBytes = new byte[TOKEN_BYTES]; secureRandom.nextBytes(randomBytes); String rawToken = TOKEN_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); String tokenHash = sha256(rawToken); String prefix = rawToken.substring(0, Math.min(rawToken.length(), 8)); - ApiToken token = new ApiToken(userId, name, prefix, tokenHash, scopeJson); - token = tokenRepo.save(token); + ApiToken token = new ApiToken(userId, normalizedName, prefix, tokenHash, scopeJson); + try { + token = tokenRepo.save(token); + } catch (DataIntegrityViolationException ex) { + throw new DomainBadRequestException("error.token.name.duplicate"); + } return new TokenCreateResult(rawToken, token); } @@ -76,4 +86,23 @@ public class ApiTokenService { throw new RuntimeException("SHA-256 not available", e); } } + + private String normalizeName(String name) { + if (name == null) { + return ""; + } + return name.trim(); + } + + private void validateTokenName(String userId, String name) { + if (name.isBlank()) { + throw new DomainBadRequestException("validation.token.name.notBlank"); + } + if (name.length() > MAX_NAME_LENGTH) { + throw new DomainBadRequestException("validation.token.name.size"); + } + if (tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase(userId, name)) { + throw new DomainBadRequestException("error.token.name.duplicate"); + } + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java new file mode 100644 index 00000000..d23e4c9c --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java @@ -0,0 +1,65 @@ +package com.iflytek.skillhub.auth.token; + +import com.iflytek.skillhub.auth.repository.ApiTokenRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ApiTokenServiceTest { + + @Mock + private ApiTokenRepository tokenRepo; + + private ApiTokenService service; + + @BeforeEach + void setUp() { + service = new ApiTokenService(tokenRepo); + } + + @Test + void createToken_rejectsNamesLongerThan64Characters() { + String longName = "a".repeat(65); + + assertThatThrownBy(() -> service.createToken("user-1", longName, "[]")) + .isInstanceOf(DomainBadRequestException.class) + .hasMessageContaining("validation.token.name.size"); + + verify(tokenRepo, never()).save(any()); + } + + @Test + void createToken_rejectsDuplicateActiveNamesIgnoringCase() { + when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "My Token")) + .thenReturn(true); + + assertThatThrownBy(() -> service.createToken("user-1", " My Token ", "[]")) + .isInstanceOf(DomainBadRequestException.class) + .hasMessageContaining("error.token.name.duplicate"); + + verify(tokenRepo, never()).save(any()); + } + + @Test + void createToken_trimsNameBeforeCheckingDuplicates() { + when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "My Token")) + .thenReturn(true); + + assertThatThrownBy(() -> service.createToken("user-1", " My Token ", "[]")) + .isInstanceOf(DomainBadRequestException.class); + + verify(tokenRepo).existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "My Token"); + verify(tokenRepo, never()).save(any()); + } +} diff --git a/web/src/features/token/create-token-dialog.tsx b/web/src/features/token/create-token-dialog.tsx index 7b2c22e9..d23e9959 100644 --- a/web/src/features/token/create-token-dialog.tsx +++ b/web/src/features/token/create-token-dialog.tsx @@ -19,33 +19,57 @@ import type { CreateTokenRequest, CreateTokenResponse } from '@/api/types' interface CreateTokenDialogProps { children: React.ReactNode + existingNames?: string[] } -export function CreateTokenDialog({ children }: CreateTokenDialogProps) { +const MAX_TOKEN_NAME_LENGTH = 64 + +export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenDialogProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) const [name, setName] = useState('') const [createdToken, setCreatedToken] = useState(null) + const [nameError, setNameError] = useState(null) const queryClient = useQueryClient() + const normalizedName = name.trim() + const hasDuplicateName = existingNames.some( + (existingName) => existingName.trim().toLocaleLowerCase() === normalizedName.toLocaleLowerCase() + ) + const createMutation = useMutation({ mutationFn: (request: CreateTokenRequest) => tokenApi.createToken(request), onSuccess: (data) => { setCreatedToken(data) setName('') + setNameError(null) queryClient.invalidateQueries({ queryKey: ['tokens'] }) }, }) const handleCreate = () => { - if (!name.trim()) return - createMutation.mutate({ name: name.trim() }) + if (!normalizedName) { + setNameError(t('createToken.nameRequired')) + return + } + if (normalizedName.length > MAX_TOKEN_NAME_LENGTH) { + setNameError(t('createToken.nameTooLong', { max: MAX_TOKEN_NAME_LENGTH })) + return + } + if (hasDuplicateName) { + setNameError(t('createToken.nameDuplicate')) + return + } + + setNameError(null) + createMutation.mutate({ name: normalizedName }) } const handleClose = () => { setOpen(false) setCreatedToken(null) setName('') + setNameError(null) createMutation.reset() } @@ -92,22 +116,40 @@ export function CreateTokenDialog({ children }: CreateTokenDialogProps) { id="token-name" placeholder={t('createToken.namePlaceholder')} value={name} - onChange={(e) => setName(e.target.value)} + maxLength={MAX_TOKEN_NAME_LENGTH} + onChange={(e) => { + setName(e.target.value) + if (nameError) { + setNameError(null) + } + }} onKeyDown={(e) => { if (e.key === 'Enter') { handleCreate() } }} + aria-invalid={nameError || hasDuplicateName ? 'true' : 'false'} /> +
+ + {nameError ?? (hasDuplicateName && normalizedName ? t('createToken.nameDuplicate') : '')} + + + {normalizedName.length}/{MAX_TOKEN_NAME_LENGTH} + +
+ {createMutation.error ? ( +

{createMutation.error.message}

+ ) : null} diff --git a/web/src/features/token/token-list.tsx b/web/src/features/token/token-list.tsx index 9e9a53fb..55daa5c2 100644 --- a/web/src/features/token/token-list.tsx +++ b/web/src/features/token/token-list.tsx @@ -62,7 +62,7 @@ export function TokenList() {

{t('token.title')}

- + token.name)}>
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index d7dd06d6..87c94597 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -398,6 +398,9 @@ "description": "Create a new API Token for CLI or API access", "nameLabel": "Token Name", "namePlaceholder": "e.g.: my-cli-token", + "nameRequired": "Token name is required", + "nameTooLong": "Token name must be at most {{max}} characters", + "nameDuplicate": "You already have a token with this name", "creating": "Creating...", "create": "Create", "successTitle": "Token Created", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 0747b9c0..9a4aad3b 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -398,6 +398,9 @@ "description": "创建一个新的 API Token 用于 CLI 或 API 访问", "nameLabel": "Token 名称", "namePlaceholder": "例如: my-cli-token", + "nameRequired": "请输入 Token 名称", + "nameTooLong": "Token 名称最多 {{max}} 个字符", + "nameDuplicate": "你已经有同名 Token", "creating": "创建中...", "create": "创建", "successTitle": "Token 创建成功",