K-VAULT / functions /utils /api-token.js
spongyicybulk's picture
feat: 适配 HuggingFace Space 单容器部署
c8cfaf5 verified
Raw
History Blame Contribute Delete
9.93 kB
const TOKEN_PREFIX = 'kvault_';
const TOKEN_KEY_PREFIX = 'api_token:';
const VALID_SCOPES = new Set(['upload', 'read', 'delete', 'paste']);
const TOKEN_ID_LENGTH = 12;
const TOKEN_SECRET_LENGTH = 40;
const TOKEN_SALT_LENGTH = 16;
const MASK_PREFIX = '******';
function timingSafeEqual(a, b) {
if (a.length !== b.length) return false;
let mismatch = 0;
for (let i = 0; i < a.length; i += 1) {
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return mismatch === 0;
}
function sanitizeTokenId(rawValue = '') {
const value = String(rawValue || '').trim();
if (!/^[A-Za-z0-9_-]{6,128}$/.test(value)) return '';
return value;
}
function randomString(length) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
let output = '';
for (let i = 0; i < length; i += 1) {
output += chars[bytes[i] % chars.length];
}
return output;
}
function splitToken(rawToken = '') {
const value = String(rawToken || '').trim();
const match = /^kvault_([A-Za-z0-9_-]{6,128})_([A-Za-z0-9_-]{16,256})$/.exec(value);
if (!match) return null;
return {
tokenId: match[1],
secret: match[2],
value,
};
}
function normalizeExpiresAt(rawValue) {
if (rawValue == null || rawValue === '') return null;
if (typeof rawValue === 'number') {
return Number.isFinite(rawValue) ? Math.max(0, Math.floor(rawValue)) : null;
}
const text = String(rawValue).trim();
if (!text) return null;
const numeric = Number(text);
if (Number.isFinite(numeric)) {
return Math.max(0, Math.floor(numeric));
}
const parsed = Date.parse(text);
if (Number.isFinite(parsed)) {
return Math.max(0, Math.floor(parsed));
}
return null;
}
function normalizeScopes(rawScopes = []) {
const list = Array.isArray(rawScopes) ? rawScopes : [rawScopes];
const normalized = [];
list.forEach((item) => {
const scope = String(item || '').trim().toLowerCase();
if (!VALID_SCOPES.has(scope)) return;
if (normalized.includes(scope)) return;
normalized.push(scope);
});
return normalized;
}
async function sha256Hex(input) {
const bytes = new TextEncoder().encode(String(input || ''));
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
async function hashTokenSecret(secret, salt) {
return sha256Hex(`${salt}:${secret}`);
}
function maskTokenSuffix(suffix = '') {
const normalized = String(suffix || '');
return `${MASK_PREFIX}${normalized}`;
}
function ensureKvBinding(env) {
if (!env?.img_url) {
throw new Error('KV binding img_url is not configured.');
}
}
function toPublicRecord(record = {}) {
return {
id: record.id,
name: record.name,
scopes: Array.isArray(record.scopes) ? [...record.scopes] : [],
expiresAt: record.expiresAt ?? null,
createdAt: Number(record.createdAt || 0),
lastUsedAt: record.lastUsedAt ?? null,
enabled: Boolean(record.enabled),
tokenPreview: record.tokenPreview || maskTokenSuffix(record.tokenSuffix || ''),
};
}
async function getRecordById(tokenId, env) {
ensureKvBinding(env);
const id = sanitizeTokenId(tokenId);
if (!id) return null;
const value = await env.img_url.get(`${TOKEN_KEY_PREFIX}${id}`, { type: 'json' });
if (!value || typeof value !== 'object') return null;
return {
...value,
id,
scopes: normalizeScopes(value.scopes || []),
enabled: value.enabled !== false,
expiresAt: normalizeExpiresAt(value.expiresAt),
createdAt: Number(value.createdAt || 0),
lastUsedAt: value.lastUsedAt == null ? null : Number(value.lastUsedAt || 0),
};
}
async function putRecord(record, env) {
ensureKvBinding(env);
await env.img_url.put(`${TOKEN_KEY_PREFIX}${record.id}`, JSON.stringify(record));
}
async function generateUniqueTokenId(env, maxAttempts = 10) {
for (let i = 0; i < maxAttempts; i += 1) {
const candidate = randomString(TOKEN_ID_LENGTH);
const exists = await env.img_url.get(`${TOKEN_KEY_PREFIX}${candidate}`);
if (exists == null) {
return candidate;
}
}
throw new Error('Failed to generate a unique token id.');
}
export function getApiTokenScopes() {
return [...VALID_SCOPES];
}
export function parseBearerToken(request) {
const authorization = String(request.headers.get('Authorization') || '').trim();
if (!authorization) return '';
const match = /^Bearer\s+(.+)$/i.exec(authorization);
if (!match) return '';
return String(match[1] || '').trim();
}
export async function createApiToken({ name, scopes, expiresAt, enabled = true }, env) {
ensureKvBinding(env);
const normalizedName = String(name || '').trim();
if (!normalizedName) {
throw new Error('Token name is required.');
}
const normalizedScopes = normalizeScopes(scopes);
if (normalizedScopes.length === 0) {
throw new Error('At least one valid scope is required.');
}
const tokenId = await generateUniqueTokenId(env);
const tokenSecret = randomString(TOKEN_SECRET_LENGTH);
const tokenSalt = randomString(TOKEN_SALT_LENGTH);
const tokenHash = await hashTokenSecret(tokenSecret, tokenSalt);
const tokenSuffix = tokenSecret.slice(-6);
const now = Date.now();
const record = {
id: tokenId,
name: normalizedName,
scopes: normalizedScopes,
expiresAt: normalizeExpiresAt(expiresAt),
createdAt: now,
lastUsedAt: null,
enabled: enabled !== false,
tokenSalt,
tokenHash,
tokenSuffix,
tokenPreview: maskTokenSuffix(tokenSuffix),
};
await putRecord(record, env);
return {
token: `${TOKEN_PREFIX}${tokenId}_${tokenSecret}`,
record: toPublicRecord(record),
};
}
export async function listApiTokens(env) {
ensureKvBinding(env);
const keys = [];
let cursor = undefined;
let guard = 0;
do {
const page = await env.img_url.list({
prefix: TOKEN_KEY_PREFIX,
limit: 1000,
cursor,
});
keys.push(...(page.keys || []).map((item) => item.name));
cursor = page.list_complete ? undefined : page.cursor;
guard += 1;
} while (cursor && guard < 10000);
const records = await Promise.all(
keys.map(async (key) => {
const id = key.slice(TOKEN_KEY_PREFIX.length);
return getRecordById(id, env);
})
);
return records
.filter(Boolean)
.sort((left, right) => Number(right.createdAt || 0) - Number(left.createdAt || 0))
.map((record) => toPublicRecord(record));
}
export async function updateApiToken(tokenId, patch = {}, env) {
const record = await getRecordById(tokenId, env);
if (!record) return null;
const next = { ...record };
if (Object.prototype.hasOwnProperty.call(patch, 'enabled')) {
next.enabled = Boolean(patch.enabled);
}
if (Object.prototype.hasOwnProperty.call(patch, 'name')) {
const normalizedName = String(patch.name || '').trim();
if (!normalizedName) {
throw new Error('Token name is required.');
}
next.name = normalizedName;
}
if (Object.prototype.hasOwnProperty.call(patch, 'scopes')) {
const normalizedScopes = normalizeScopes(patch.scopes);
if (normalizedScopes.length === 0) {
throw new Error('At least one valid scope is required.');
}
next.scopes = normalizedScopes;
}
if (Object.prototype.hasOwnProperty.call(patch, 'expiresAt')) {
next.expiresAt = normalizeExpiresAt(patch.expiresAt);
}
await putRecord(next, env);
return toPublicRecord(next);
}
export async function deleteApiToken(tokenId, env) {
ensureKvBinding(env);
const id = sanitizeTokenId(tokenId);
if (!id) return false;
const existing = await getRecordById(id, env);
if (!existing) return false;
await env.img_url.delete(`${TOKEN_KEY_PREFIX}${id}`);
return true;
}
export async function touchApiTokenLastUsed(tokenId, env) {
const record = await getRecordById(tokenId, env);
if (!record) return false;
record.lastUsedAt = Date.now();
await putRecord(record, env);
return true;
}
export async function verifyApiToken(tokenValue, env, requiredScope = '') {
const split = splitToken(tokenValue);
if (!split) {
return {
ok: false,
status: 401,
code: 'TOKEN_INVALID',
message: 'API Token is invalid.',
};
}
const record = await getRecordById(split.tokenId, env);
if (!record) {
return {
ok: false,
status: 401,
code: 'TOKEN_INVALID',
message: 'API Token is invalid.',
};
}
const expectedHash = await hashTokenSecret(split.secret, record.tokenSalt || '');
if (!timingSafeEqual(expectedHash, String(record.tokenHash || ''))) {
return {
ok: false,
status: 401,
code: 'TOKEN_INVALID',
message: 'API Token is invalid.',
};
}
if (!record.enabled) {
return {
ok: false,
status: 401,
code: 'TOKEN_DISABLED',
message: 'API Token is disabled.',
};
}
if (Number.isFinite(record.expiresAt) && record.expiresAt > 0 && Date.now() > record.expiresAt) {
return {
ok: false,
status: 401,
code: 'TOKEN_EXPIRED',
message: 'API Token has expired.',
};
}
const normalizedScope = String(requiredScope || '').trim().toLowerCase();
if (normalizedScope && !record.scopes.includes(normalizedScope)) {
return {
ok: false,
status: 403,
code: 'TOKEN_SCOPE_DENIED',
message: `API Token does not include "${normalizedScope}" scope.`,
};
}
return {
ok: true,
token: toPublicRecord(record),
};
}