mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-09 03:17:52 +00:00
fix(token): validate name length and uniqueness
This commit is contained in:
parent
ccc23289bc
commit
fc9bdffc7d
13 changed files with 191 additions and 9 deletions
|
|
@ -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
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=用户名已存在
|
||||
|
|
|
|||
|
|
@ -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 个字符"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 创建成功",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue