enhancement: make api tokens viewable (#23439)

This commit is contained in:
Bassel Kanso 2025-05-05 11:06:33 +03:00 committed by GitHub
parent c8de96f027
commit 5989d3c7be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 357 additions and 63 deletions

View File

@ -14,6 +14,9 @@ module.exports = ({ env }) => ({
salt: env('TRANSFER_TOKEN_SALT', 'example-salt'), salt: env('TRANSFER_TOKEN_SALT', 'example-salt'),
}, },
}, },
secrets: {
encryptionKey: env('ENCRYPTION_KEY', 'example-key'),
},
flags: { flags: {
nps: env.bool('FLAG_NPS', true), nps: env.bool('FLAG_NPS', true),
promoteEE: env.bool('FLAG_PROMOTE_EE', true), promoteEE: env.bool('FLAG_PROMOTE_EE', true),

View File

@ -15,6 +15,7 @@ APP_KEYS=<%= appKeys %>
API_TOKEN_SALT=<%= apiTokenSalt %> API_TOKEN_SALT=<%= apiTokenSalt %>
ADMIN_JWT_SECRET=<%= adminJwtToken %> ADMIN_JWT_SECRET=<%= adminJwtToken %>
TRANSFER_TOKEN_SALT=<%= transferTokenSalt %> TRANSFER_TOKEN_SALT=<%= transferTokenSalt %>
ENCRYPTION_KEY=<%= encryptionKey %>
# Database # Database
DATABASE_CLIENT=<%= database.client %> DATABASE_CLIENT=<%= database.client %>
@ -35,6 +36,7 @@ export function generateDotEnv(scope: Scope) {
apiTokenSalt: generateASecret(), apiTokenSalt: generateASecret(),
transferTokenSalt: generateASecret(), transferTokenSalt: generateASecret(),
adminJwtToken: generateASecret(), adminJwtToken: generateASecret(),
encryptionKey: generateASecret(),
database: { database: {
client: scope.database.client, client: scope.database.client,
connection: { connection: {

View File

@ -5,3 +5,4 @@ API_TOKEN_SALT=tobemodified
ADMIN_JWT_SECRET=tobemodified ADMIN_JWT_SECRET=tobemodified
TRANSFER_TOKEN_SALT=tobemodified TRANSFER_TOKEN_SALT=tobemodified
JWT_SECRET=tobemodified JWT_SECRET=tobemodified
ENCRYPTION_KEY=tobemodified

View File

@ -10,6 +10,9 @@ module.exports = ({ env }) => ({
salt: env('TRANSFER_TOKEN_SALT'), salt: env('TRANSFER_TOKEN_SALT'),
}, },
}, },
secrets: {
encryptionKey: env('ENCRYPTION_KEY'),
},
flags: { flags: {
nps: env.bool('FLAG_NPS', true), nps: env.bool('FLAG_NPS', true),
promoteEE: env.bool('FLAG_PROMOTE_EE', true), promoteEE: env.bool('FLAG_PROMOTE_EE', true),

View File

@ -5,3 +5,4 @@ API_TOKEN_SALT=tobemodified
ADMIN_JWT_SECRET=tobemodified ADMIN_JWT_SECRET=tobemodified
TRANSFER_TOKEN_SALT=tobemodified TRANSFER_TOKEN_SALT=tobemodified
JWT_SECRET=tobemodified JWT_SECRET=tobemodified
ENCRYPTION_KEY=tobemodified

View File

@ -10,6 +10,9 @@ export default ({ env }) => ({
salt: env('TRANSFER_TOKEN_SALT'), salt: env('TRANSFER_TOKEN_SALT'),
}, },
}, },
secrets: {
encryptionKey: env('ENCRYPTION_KEY'),
},
flags: { flags: {
nps: env.bool('FLAG_NPS', true), nps: env.bool('FLAG_NPS', true),
promoteEE: env.bool('FLAG_PROMOTE_EE', true), promoteEE: env.bool('FLAG_PROMOTE_EE', true),

View File

@ -5,3 +5,4 @@ API_TOKEN_SALT=tobemodified
ADMIN_JWT_SECRET=tobemodified ADMIN_JWT_SECRET=tobemodified
TRANSFER_TOKEN_SALT=tobemodified TRANSFER_TOKEN_SALT=tobemodified
JWT_SECRET=tobemodified JWT_SECRET=tobemodified
ENCRYPTION_KEY=tobemodified

View File

@ -5,3 +5,4 @@ API_TOKEN_SALT=tobemodified
ADMIN_JWT_SECRET=tobemodified ADMIN_JWT_SECRET=tobemodified
TRANSFER_TOKEN_SALT=tobemodified TRANSFER_TOKEN_SALT=tobemodified
JWT_SECRET=tobemodified JWT_SECRET=tobemodified
ENCRYPTION_KEY=tobemodified

View File

@ -10,6 +10,9 @@ export default ({ env }) => ({
salt: env('TRANSFER_TOKEN_SALT'), salt: env('TRANSFER_TOKEN_SALT'),
}, },
}, },
secrets: {
encryptionKey: env('ENCRYPTION_KEY'),
},
flags: { flags: {
nps: env.bool('FLAG_NPS', true), nps: env.bool('FLAG_NPS', true),
promoteEE: env.bool('FLAG_PROMOTE_EE', true), promoteEE: env.bool('FLAG_PROMOTE_EE', true),

View File

@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { Button, Dialog, Flex } from '@strapi/design-system'; import { Button, Dialog, Flex, Tooltip } from '@strapi/design-system';
import { Check, ArrowClockwise } from '@strapi/icons'; import { Check, ArrowClockwise, Eye, EyeStriked } from '@strapi/icons';
import { MessageDescriptor, useIntl } from 'react-intl'; import { MessageDescriptor, useIntl } from 'react-intl';
import { ConfirmDialog } from '../../../../components/ConfirmDialog'; import { ConfirmDialog } from '../../../../components/ConfirmDialog';
@ -117,7 +117,10 @@ interface FormHeadProps<TToken extends Token | null> {
token: TToken; token: TToken;
canEditInputs: boolean; canEditInputs: boolean;
canRegenerate: boolean; canRegenerate: boolean;
canShowToken?: boolean;
setToken: (token: TToken) => void; setToken: (token: TToken) => void;
toggleToken?: () => void;
showToken?: boolean;
isSubmitting: boolean; isSubmitting: boolean;
regenerateUrl: string; regenerateUrl: string;
} }
@ -126,6 +129,9 @@ export const FormHead = <TToken extends Token | null>({
title, title,
token, token,
setToken, setToken,
toggleToken,
showToken,
canShowToken,
canEditInputs, canEditInputs,
canRegenerate, canRegenerate,
isSubmitting, isSubmitting,
@ -137,6 +143,7 @@ export const FormHead = <TToken extends Token | null>({
...token, ...token,
accessKey: newKey, accessKey: newKey,
}); });
toggleToken?.();
}; };
return ( return (
@ -151,6 +158,31 @@ export const FormHead = <TToken extends Token | null>({
url={`${regenerateUrl}${token?.id ?? ''}`} url={`${regenerateUrl}${token?.id ?? ''}`}
/> />
)} )}
{token?.id && toggleToken && (
<Tooltip
label={
!canShowToken &&
formatMessage({
id: 'Settings.tokens.encryptionKeyMissing',
defaultMessage:
'In order to view the token, you need a valid encryption key in the admin configuration',
})
}
>
<Button
type="button"
startIcon={showToken ? <EyeStriked /> : <Eye />}
variant="secondary"
onClick={() => toggleToken?.()}
disabled={!canShowToken}
>
{formatMessage({
id: 'Settings.tokens.viewToken',
defaultMessage: 'View token',
})}
</Button>
</Tooltip>
)}
<Button <Button
disabled={isSubmitting} disabled={isSubmitting}
loading={isSubmitting} loading={isSubmitting}

View File

@ -64,10 +64,18 @@ export const TokenBox = ({ token, tokenType }: TokenBoxProps) => {
} }
subtitle={ subtitle={
token token
? formatMessage({ ? formatMessage(
tokenType === 'api-token'
? {
id: 'Settings.tokens.copy.subtitle',
defaultMessage: 'Copy this token for use elsewhere',
}
: {
id: 'Settings.tokens.copy.lastWarning', id: 'Settings.tokens.copy.lastWarning',
defaultMessage: 'Make sure to copy this token, you wont be able to see it again!', defaultMessage:
}) 'Make sure to copy this token, you wont be able to see it again!',
}
)
: formatMessage({ : formatMessage({
id: 'Settings.tokens.copy.editMessage', id: 'Settings.tokens.copy.editMessage',
defaultMessage: 'For security reasons, you can only see your token once.', defaultMessage: 'For security reasons, you can only see your token once.',

View File

@ -51,6 +51,8 @@ export const EditView = () => {
} }
: null : null
); );
const [showToken, setShowToken] = React.useState(Boolean(locationState?.apiToken?.accessKey));
const hideTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const { trackUsage } = useTracking(); const { trackUsage } = useTracking();
const setCurrentStep = useGuidedTour('EditView', (state) => state.setCurrentStep); const setCurrentStep = useGuidedTour('EditView', (state) => state.setCurrentStep);
const { const {
@ -69,7 +71,6 @@ export const EditView = () => {
const contentAPIPermissionsQuery = useGetPermissionsQuery(); const contentAPIPermissionsQuery = useGetPermissionsQuery();
const contentAPIRoutesQuery = useGetRoutesQuery(); const contentAPIRoutesQuery = useGetRoutesQuery();
/** /**
* Separate effects otherwise we could end * Separate effects otherwise we could end
* up duplicating the same notification. * up duplicating the same notification.
@ -173,6 +174,23 @@ export const EditView = () => {
} }
}, [data]); }, [data]);
React.useEffect(() => {
// Only set up timer when token is shown
if (showToken) {
hideTimerRef.current = setTimeout(() => {
setShowToken(false);
}, 30000); // 30 seconds
// Cleanup on unmount or when showToken changes
return () => {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
};
}
}, [showToken]);
const [createToken] = useCreateAPITokenMutation(); const [createToken] = useCreateAPITokenMutation();
const [updateToken] = useUpdateAPITokenMutation(); const [updateToken] = useUpdateAPITokenMutation();
@ -304,6 +322,14 @@ export const EditView = () => {
}); });
}; };
const toggleToken = () => {
setShowToken((prev) => !prev);
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
};
const providerValue = { const providerValue = {
...state, ...state,
onChange: handleChangeCheckbox, onChange: handleChangeCheckbox,
@ -312,6 +338,7 @@ export const EditView = () => {
}; };
const canEditInputs = (canUpdate && !isCreating) || (canCreate && isCreating); const canEditInputs = (canUpdate && !isCreating) || (canCreate && isCreating);
const canShowToken = !!apiToken?.accessKey;
if (isLoading) { if (isLoading) {
return <Page.Loading />; return <Page.Loading />;
@ -352,17 +379,21 @@ export const EditView = () => {
}} }}
token={apiToken} token={apiToken}
setToken={setApiToken} setToken={setApiToken}
toggleToken={toggleToken}
showToken={showToken}
canEditInputs={canEditInputs} canEditInputs={canEditInputs}
canRegenerate={canRegenerate} canRegenerate={canRegenerate}
canShowToken={canShowToken}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
regenerateUrl="/admin/api-tokens/" regenerateUrl="/admin/api-tokens/"
/> />
<Layouts.Content> <Layouts.Content>
<Flex direction="column" alignItems="stretch" gap={6}> <Flex direction="column" alignItems="stretch" gap={6}>
{Boolean(apiToken?.name) && ( {apiToken?.accessKey && showToken && (
<TokenBox token={apiToken?.accessKey} tokenType={API_TOKEN_TYPE} /> <TokenBox token={apiToken.accessKey} tokenType={API_TOKEN_TYPE} />
)} )}
<FormApiTokenContainer <FormApiTokenContainer
errors={errors} errors={errors}
onChange={handleChange} onChange={handleChange}

View File

@ -263,6 +263,7 @@ const EditView = () => {
}} }}
token={transferToken} token={transferToken}
setToken={setTransferToken} setToken={setTransferToken}
canShowToken={false}
canEditInputs={canEditInputs} canEditInputs={canEditInputs}
canRegenerate={canRegenerate} canRegenerate={canRegenerate}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}

View File

@ -47,6 +47,13 @@ export default {
required: true, required: true,
searchable: false, searchable: false,
}, },
encryptedKey: {
type: 'string',
minLength: 1,
configurable: false,
required: false,
searchable: false,
},
lastUsedAt: { lastUsedAt: {
type: 'datetime', type: 'datetime',
configurable: false, configurable: false,

View File

@ -13,6 +13,7 @@ import {
update as apiTokenUpdate, update as apiTokenUpdate,
getByName, getByName,
} from '../api-token'; } from '../api-token';
import encryptionService from '../encryption';
const getActionProvider = (actions = []) => { const getActionProvider = (actions = []) => {
return { return {
@ -26,6 +27,30 @@ describe('API Token', () => {
hexedString: '6170692d746f6b656e5f746573742d72616e646f6d2d6279746573', hexedString: '6170692d746f6b656e5f746573742d72616e646f6d2d6279746573',
}; };
const ENCRYPTION_KEY = crypto.randomBytes(32).toString('hex');
const setupStrapiMock = (overrides = {}) => {
global.strapi = {
db: {
query: jest.fn(() => ({})),
},
config: {
get: jest.fn((key) => {
if (key === 'admin.secrets.encryptionKey') {
return ENCRYPTION_KEY;
}
return '';
}),
},
admin: {
services: {
encryption: encryptionService,
},
},
...overrides,
} as any;
};
let now: any; let now: any;
let nowSpy: any; let nowSpy: any;
@ -48,16 +73,13 @@ describe('API Token', () => {
test('Creates a new read-only token', async () => { test('Creates a new read-only token', async () => {
const create = jest.fn(({ data }) => Promise.resolve(data)); const create = jest.fn(({ data }) => Promise.resolve(data));
global.strapi = { setupStrapiMock({
db: { db: {
query() { query() {
return { create }; return { create };
}, },
}, },
config: { });
get: jest.fn(() => ''),
},
} as any;
const attributes = { const attributes = {
name: 'api-token_tests-name', name: 'api-token_tests-name',
@ -72,6 +94,7 @@ describe('API Token', () => {
data: { data: {
...attributes, ...attributes,
accessKey: hash(mockedApiToken.hexedString), accessKey: hash(mockedApiToken.hexedString),
encryptedKey: expect.any(String),
expiresAt: null, expiresAt: null,
lifespan: null, lifespan: null,
}, },
@ -80,6 +103,7 @@ describe('API Token', () => {
expect(res).toEqual({ expect(res).toEqual({
...attributes, ...attributes,
accessKey: mockedApiToken.hexedString, accessKey: mockedApiToken.hexedString,
encryptedKey: expect.any(String),
expiresAt: null, expiresAt: null,
lifespan: null, lifespan: null,
}); });
@ -96,16 +120,13 @@ describe('API Token', () => {
const expectedExpires = Date.now() + attributes.lifespan; const expectedExpires = Date.now() + attributes.lifespan;
const create = jest.fn(({ data }) => Promise.resolve(data)); const create = jest.fn(({ data }) => Promise.resolve(data));
global.strapi = { setupStrapiMock({
db: { db: {
query() { query() {
return { create }; return { create };
}, },
}, },
config: { });
get: jest.fn(() => ''),
},
} as any;
const res = await apiTokenCreate(attributes); const res = await apiTokenCreate(attributes);
@ -114,6 +135,7 @@ describe('API Token', () => {
data: { data: {
...attributes, ...attributes,
accessKey: hash(mockedApiToken.hexedString), accessKey: hash(mockedApiToken.hexedString),
encryptedKey: expect.any(String),
expiresAt: expectedExpires, expiresAt: expectedExpires,
lifespan: attributes.lifespan, lifespan: attributes.lifespan,
}, },
@ -122,6 +144,7 @@ describe('API Token', () => {
expect(res).toEqual({ expect(res).toEqual({
...attributes, ...attributes,
accessKey: mockedApiToken.hexedString, accessKey: mockedApiToken.hexedString,
encryptedKey: expect.any(String),
expiresAt: expectedExpires, expiresAt: expectedExpires,
lifespan: attributes.lifespan, lifespan: attributes.lifespan,
}); });
@ -137,16 +160,13 @@ describe('API Token', () => {
} as any; } as any;
const create = jest.fn(({ data }) => Promise.resolve(data)); const create = jest.fn(({ data }) => Promise.resolve(data));
global.strapi = { setupStrapiMock({
db: { db: {
query() { query() {
return { create }; return { create };
}, },
}, },
config: { });
get: jest.fn(() => ''),
},
} as any;
expect(async () => { expect(async () => {
await apiTokenCreate(attributes); await apiTokenCreate(attributes);
@ -182,7 +202,7 @@ describe('API Token', () => {
) )
); );
global.strapi = { setupStrapiMock({
...getActionProvider(['admin::content.content.read'] as any), ...getActionProvider(['admin::content.content.read'] as any),
db: { db: {
query() { query() {
@ -193,10 +213,7 @@ describe('API Token', () => {
}; };
}, },
}, },
config: { });
get: jest.fn(() => ''),
},
} as any;
const res = await apiTokenCreate(attributes); const res = await apiTokenCreate(attributes);
@ -213,6 +230,7 @@ describe('API Token', () => {
data: { data: {
...omit('permissions', attributes), ...omit('permissions', attributes),
accessKey: hash(mockedApiToken.hexedString), accessKey: hash(mockedApiToken.hexedString),
encryptedKey: expect.any(String),
expiresAt: null, expiresAt: null,
lifespan: null, lifespan: null,
}, },
@ -265,7 +283,7 @@ describe('API Token', () => {
) )
); );
global.strapi = { setupStrapiMock({
...getActionProvider(['admin::content.content.read'] as any), ...getActionProvider(['admin::content.content.read'] as any),
db: { db: {
query() { query() {
@ -276,10 +294,7 @@ describe('API Token', () => {
}; };
}, },
}, },
config: { });
get: jest.fn(() => ''),
},
} as any;
const res = await apiTokenCreate(attributes); const res = await apiTokenCreate(attributes);
@ -297,6 +312,7 @@ describe('API Token', () => {
data: { data: {
...omit('permissions', attributes), ...omit('permissions', attributes),
accessKey: hash(mockedApiToken.hexedString), accessKey: hash(mockedApiToken.hexedString),
encryptedKey: expect.any(String),
expiresAt: null, expiresAt: null,
lifespan: null, lifespan: null,
}, },
@ -338,7 +354,7 @@ describe('API Token', () => {
) )
); );
global.strapi = { setupStrapiMock({
...getActionProvider(['api::foo.foo.find', 'api::foo.foo.create'] as any), ...getActionProvider(['api::foo.foo.find', 'api::foo.foo.create'] as any),
db: { db: {
query() { query() {
@ -349,10 +365,7 @@ describe('API Token', () => {
}; };
}, },
}, },
config: { });
get: jest.fn(() => ''),
},
} as any;
const res = await apiTokenCreate(attributes); const res = await apiTokenCreate(attributes);
@ -386,7 +399,7 @@ describe('API Token', () => {
) )
); );
global.strapi = { setupStrapiMock({
...getActionProvider(['valid-permission'] as any), ...getActionProvider(['valid-permission'] as any),
db: { db: {
query() { query() {
@ -396,10 +409,7 @@ describe('API Token', () => {
}; };
}, },
}, },
config: { });
get: jest.fn(() => ''),
},
} as any;
await expect(() => apiTokenCreate(attributes)).rejects.toThrowError( await expect(() => apiTokenCreate(attributes)).rejects.toThrowError(
new errors.ApplicationError( new errors.ApplicationError(
@ -426,6 +436,15 @@ describe('API Token', () => {
}, },
} as any; } as any;
setupStrapiMock({
config: {
get: jest.fn(() => ({
admin: { apiToken: { salt: 'api-token_tests-salt' } },
})),
set: mockedConfigSet,
},
});
checkSaltIsDefined(); checkSaltIsDefined();
expect(mockedAppendFile).not.toHaveBeenCalled(); expect(mockedAppendFile).not.toHaveBeenCalled();
@ -433,11 +452,11 @@ describe('API Token', () => {
}); });
test('It throws if the salt is not defined', () => { test('It throws if the salt is not defined', () => {
global.strapi = { setupStrapiMock({
config: { config: {
get: jest.fn(() => null), get: jest.fn(() => null),
}, },
} as any; });
try { try {
checkSaltIsDefined(); checkSaltIsDefined();
@ -611,17 +630,13 @@ describe('API Token', () => {
test('It regenerates the accessKey', async () => { test('It regenerates the accessKey', async () => {
const update = jest.fn(({ data }) => Promise.resolve(data)); const update = jest.fn(({ data }) => Promise.resolve(data));
global.strapi = { setupStrapiMock({
db: { db: {
query() { query() {
return { update }; return { update };
}, },
}, },
config: { });
get: jest.fn(() => ''),
},
} as any;
const id = 1; const id = 1;
const res = await regenerate(id); const res = await regenerate(id);
@ -630,24 +645,25 @@ describe('API Token', () => {
select: ['id', 'accessKey'], select: ['id', 'accessKey'],
data: { data: {
accessKey: hash(mockedApiToken.hexedString), accessKey: hash(mockedApiToken.hexedString),
encryptedKey: expect.any(String),
}, },
}); });
expect(res).toEqual({ accessKey: mockedApiToken.hexedString }); expect(res).toEqual({
accessKey: mockedApiToken.hexedString,
encryptedKey: expect.any(String),
});
}); });
test('It throws a NotFound if the id is not found', async () => { test('It throws a NotFound if the id is not found', async () => {
const update = jest.fn(() => Promise.resolve(null)); const update = jest.fn(() => Promise.resolve(null));
global.strapi = { setupStrapiMock({
db: { db: {
query() { query() {
return { update }; return { update };
}, },
}, },
config: { });
get: jest.fn(() => ''),
},
} as any;
const id = 1; const id = 1;
await expect(async () => { await expect(async () => {
@ -659,6 +675,7 @@ describe('API Token', () => {
select: ['id', 'accessKey'], select: ['id', 'accessKey'],
data: { data: {
accessKey: hash(mockedApiToken.hexedString), accessKey: hash(mockedApiToken.hexedString),
encryptedKey: expect.any(String),
}, },
}); });
}); });

View File

@ -0,0 +1,78 @@
import crypto from 'crypto';
import encryption from '../encryption';
describe('Encryption Service', () => {
const ENCRYPTION_KEY = crypto.randomBytes(32).toString('hex');
beforeEach(() => {
global.strapi = {
config: {
get: jest.fn((key) => {
if (key === 'admin.secrets.encryptionKey') {
return ENCRYPTION_KEY;
}
return undefined;
}),
},
log: {
warn: jest.fn(),
},
} as any;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('encrypt', () => {
test('encrypts and returns a four-part colon-separated string (version, iv, encrypted, auth)', () => {
const encrypted = encryption.encrypt('super secret');
expect(encrypted).toMatch(/^v1:[a-f0-9]+:[a-f0-9]+:[a-f0-9]+$/);
const parts = encrypted!.split(':');
expect(parts).toHaveLength(4);
});
test('returns null and logs warning when key is missing', () => {
(global.strapi.config.get as jest.Mock).mockReturnValue(undefined);
const result = encryption.encrypt('test');
expect(result).toBeNull();
expect(global.strapi.log.warn).toHaveBeenCalledWith('Encryption key is missing from config');
});
});
describe('decrypt', () => {
test('round-trips encrypted → decrypted', () => {
const original = 'secret message';
const encrypted = encryption.encrypt(original);
const decrypted = encryption.decrypt(encrypted!);
expect(decrypted).toBe(original);
});
test('throws for malformed input', () => {
expect(() => encryption.decrypt('v1:bad-format')).toThrow('Invalid encrypted value format');
});
test('returns null and logs warning when key is missing', () => {
(global.strapi.config.get as jest.Mock).mockReturnValue(undefined);
const result = encryption.decrypt('v1:iv:payload:tag');
expect(result).toBeNull();
expect(global.strapi.log.warn).toHaveBeenCalledWith('Encryption key is missing from config');
});
test('returns null and logs warning when decryption fails due to wrong key', () => {
const encrypted = encryption.encrypt('cannot decrypt this');
const wrongKey = crypto.randomBytes(32).toString('hex');
(global.strapi.config.get as jest.Mock).mockReturnValueOnce(wrongKey);
const result = encryption.decrypt(encrypted!);
expect(result).toBeNull();
expect(global.strapi.log.warn).toHaveBeenCalledWith(
'[decrypt] Unable to decrypt value — encryption key may have changed or data is corrupted.'
);
});
});
});

View File

@ -3,6 +3,7 @@ import { omit, difference, isNil, isEmpty, map, isArray, uniq, isNumber } from '
import { errors } from '@strapi/utils'; import { errors } from '@strapi/utils';
import type { Update, ApiToken, ApiTokenBody } from '../../../shared/contracts/api-token'; import type { Update, ApiToken, ApiTokenBody } from '../../../shared/contracts/api-token';
import constants from './constants'; import constants from './constants';
import { getService } from '../utils';
const { ValidationError, NotFoundError } = errors; const { ValidationError, NotFoundError } = errors;
@ -117,15 +118,28 @@ const getBy = async (whereParams: WhereParams = {}): Promise<ApiToken | null> =>
return null; return null;
} }
const token = await strapi.db const token = await strapi.db.query('admin::api-token').findOne({
.query('admin::api-token') select: [...SELECT_FIELDS, 'encryptedKey'],
.findOne({ select: SELECT_FIELDS, populate: POPULATE_FIELDS, where: whereParams }); populate: POPULATE_FIELDS,
where: whereParams,
});
if (!token) { if (!token) {
return token; return token;
} }
return flattenTokenPermissions(token); const { encryptedKey, ...rest } = token;
if (!encryptedKey) {
return flattenTokenPermissions(rest);
}
const accessKey = getService('encryption').decrypt(encryptedKey);
return flattenTokenPermissions({
...rest,
accessKey,
});
}; };
/** /**
@ -164,7 +178,9 @@ const getExpirationFields = (lifespan: ApiTokenBody['lifespan']) => {
* Create a token and its permissions * Create a token and its permissions
*/ */
const create = async (attributes: ApiTokenBody): Promise<ApiToken> => { const create = async (attributes: ApiTokenBody): Promise<ApiToken> => {
const encryptionService = getService('encryption');
const accessKey = crypto.randomBytes(128).toString('hex'); const accessKey = crypto.randomBytes(128).toString('hex');
const encryptedKey = encryptionService.encrypt(accessKey);
assertCustomTokenPermissionsValidity(attributes.type, attributes.permissions); assertCustomTokenPermissionsValidity(attributes.type, attributes.permissions);
assertValidLifespan(attributes.lifespan); assertValidLifespan(attributes.lifespan);
@ -176,6 +192,7 @@ const create = async (attributes: ApiTokenBody): Promise<ApiToken> => {
data: { data: {
...omit('permissions', attributes), ...omit('permissions', attributes),
accessKey: hash(accessKey), accessKey: hash(accessKey),
encryptedKey,
...getExpirationFields(attributes.lifespan), ...getExpirationFields(attributes.lifespan),
}, },
}); });
@ -211,12 +228,15 @@ const create = async (attributes: ApiTokenBody): Promise<ApiToken> => {
const regenerate = async (id: string | number): Promise<ApiToken> => { const regenerate = async (id: string | number): Promise<ApiToken> => {
const accessKey = crypto.randomBytes(128).toString('hex'); const accessKey = crypto.randomBytes(128).toString('hex');
const encryptionService = getService('encryption');
const encryptedKey = encryptionService.encrypt(accessKey);
const apiToken: ApiToken = await strapi.db.query('admin::api-token').update({ const apiToken: ApiToken = await strapi.db.query('admin::api-token').update({
select: ['id', 'accessKey'], select: ['id', 'accessKey'],
where: { id }, where: { id },
data: { data: {
accessKey: hash(accessKey), accessKey: hash(accessKey),
encryptedKey,
}, },
}); });

View File

@ -0,0 +1,77 @@
import crypto from 'crypto';
const IV_LENGTH = 16; // 16 bytes for AES-GCM IV
const ENCRYPTION_VERSION = 'v1';
const getHashedKey = (): Buffer | null => {
const rawKey: string = strapi.config.get('admin.secrets.encryptionKey');
if (!rawKey) {
strapi.log.warn('Encryption key is missing from config');
return null;
}
return crypto.createHash('sha256').update(rawKey).digest(); // Always 32 bytes
};
/**
* Encrypts a value string using AES-256-GCM.
* Returns a string prefixed with the encryption version and includes IV, encrypted content, and auth tag (all hex-encoded).
*/
const encrypt = (value: string) => {
const key = getHashedKey();
if (!key) return null;
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(value, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return `${ENCRYPTION_VERSION}:${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}`;
};
/**
* Decrypts a value encrypted by encrypt().
* Supports versioned formats like v1:iv:encrypted:authTag
*/
const decrypt = (encryptedValue: string) => {
const [version, ...rest] = encryptedValue.split(':');
if (version !== ENCRYPTION_VERSION) {
throw new Error(`Unsupported encryption version: ${version}`);
}
const [ivHex, encryptedHex, tagHex] = rest;
if (!ivHex || !encryptedHex || !tagHex) {
throw new Error('Invalid encrypted value format');
}
const key = getHashedKey();
if (!key) return null;
const iv = Buffer.from(ivHex, 'hex');
const encryptedText = Buffer.from(encryptedHex, 'hex');
const authTag = Buffer.from(tagHex, 'hex');
try {
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedText, undefined, 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (err) {
strapi.log.warn(
'[decrypt] Unable to decrypt value — encryption key may have changed or data is corrupted.'
);
return null;
}
};
export default {
encrypt,
decrypt,
};

View File

@ -4,6 +4,7 @@ import user from './user';
import role from './role'; import role from './role';
import passport from './passport'; import passport from './passport';
import metrics from './metrics'; import metrics from './metrics';
import encryption from './encryption';
import * as token from './token'; import * as token from './token';
import * as permission from './permission'; import * as permission from './permission';
import * as contentType from './content-type'; import * as contentType from './content-type';
@ -30,4 +31,5 @@ export default {
'api-token': apiToken, 'api-token': apiToken,
transfer, transfer,
'project-settings': projectSettings, 'project-settings': projectSettings,
encryption,
}; };

View File

@ -3,6 +3,7 @@ import role from '../services/role';
import user from '../services/user'; import user from '../services/user';
import passport from '../services/passport'; import passport from '../services/passport';
import metrics from '../services/metrics'; import metrics from '../services/metrics';
import encryption from '../services/encryption';
import * as permission from '../services/permission'; import * as permission from '../services/permission';
import * as contentType from '../services/content-type'; import * as contentType from '../services/content-type';
import * as token from '../services/token'; import * as token from '../services/token';
@ -22,6 +23,7 @@ type S = {
'api-token': typeof apiToken; 'api-token': typeof apiToken;
'project-settings': typeof projectSettings; 'project-settings': typeof projectSettings;
transfer: typeof transfer; transfer: typeof transfer;
encryption: typeof encryption;
}; };
type Resolve<T> = T extends (...args: unknown[]) => unknown ? T : { [K in keyof T]: T[K] }; type Resolve<T> = T extends (...args: unknown[]) => unknown ? T : { [K in keyof T]: T[K] };

View File

@ -3,6 +3,7 @@ import type { Data } from '@strapi/types';
export type ApiToken = { export type ApiToken = {
accessKey: string; accessKey: string;
encryptedKey: string;
createdAt: string; createdAt: string;
description: string; description: string;
expiresAt: string; expiresAt: string;

View File

@ -15,7 +15,7 @@ const createAPIToken = async (page, tokenName, duration, type) => {
await page.getByRole('option', { name: type }).click(); await page.getByRole('option', { name: type }).click();
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Make sure to copy this token')).toBeVisible(); await expect(page.getByText('Copy this token for use elsewhere')).toBeVisible();
await expect(page.getByText('Expiration date:')).toBeVisible(); await expect(page.getByText('Expiration date:')).toBeVisible();
}; };