fix(token): validate name length and uniqueness

This commit is contained in:
yun-zhi-ztl 2026-03-14 19:38:18 +08:00
parent ccc23289bc
commit fc9bdffc7d
13 changed files with 191 additions and 9 deletions

View file

@ -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<String> scopes
) {}

View file

@ -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;

View file

@ -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

View file

@ -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=用户名已存在

View file

@ -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 个字符"));
}
}

View file

@ -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)

View file

@ -11,4 +11,5 @@ public interface ApiTokenRepository extends JpaRepository<ApiToken, Long> {
Optional<ApiToken> findByTokenHash(String tokenHash);
List<ApiToken> findByUserId(String userId);
List<ApiToken> findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId);
boolean existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase(String userId, String name);
}

View file

@ -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");
}
}
}

View file

@ -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());
}
}

View file

@ -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<CreateTokenResponse | null>(null)
const [nameError, setNameError] = useState<string | null>(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'}
/>
<div className="flex items-center justify-between gap-3 text-xs">
<span className="text-red-600">
{nameError ?? (hasDuplicateName && normalizedName ? t('createToken.nameDuplicate') : '')}
</span>
<span className="text-muted-foreground">
{normalizedName.length}/{MAX_TOKEN_NAME_LENGTH}
</span>
</div>
</div>
</div>
{createMutation.error ? (
<p className="text-sm text-red-600">{createMutation.error.message}</p>
) : null}
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
{t('dialog.cancel')}
</Button>
<Button
onClick={handleCreate}
disabled={!name.trim() || createMutation.isPending}
disabled={!normalizedName || hasDuplicateName || createMutation.isPending}
>
{createMutation.isPending ? t('createToken.creating') : t('createToken.create')}
</Button>

View file

@ -62,7 +62,7 @@ export function TokenList() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{t('token.title')}</h2>
<CreateTokenDialog>
<CreateTokenDialog existingNames={(tokens ?? []).map((token) => token.name)}>
<Button>{t('token.createNew')}</Button>
</CreateTokenDialog>
</div>

View file

@ -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",

View file

@ -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 创建成功",