mirror of
https://github.com/strapi/strapi.git
synced 2025-06-27 00:41:25 +00:00
enhancement: make api tokens viewable (#23439)
This commit is contained in:
parent
c8de96f027
commit
5989d3c7be
@ -14,6 +14,9 @@ module.exports = ({ env }) => ({
|
||||
salt: env('TRANSFER_TOKEN_SALT', 'example-salt'),
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
encryptionKey: env('ENCRYPTION_KEY', 'example-key'),
|
||||
},
|
||||
flags: {
|
||||
nps: env.bool('FLAG_NPS', true),
|
||||
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
||||
|
@ -15,6 +15,7 @@ APP_KEYS=<%= appKeys %>
|
||||
API_TOKEN_SALT=<%= apiTokenSalt %>
|
||||
ADMIN_JWT_SECRET=<%= adminJwtToken %>
|
||||
TRANSFER_TOKEN_SALT=<%= transferTokenSalt %>
|
||||
ENCRYPTION_KEY=<%= encryptionKey %>
|
||||
|
||||
# Database
|
||||
DATABASE_CLIENT=<%= database.client %>
|
||||
@ -35,6 +36,7 @@ export function generateDotEnv(scope: Scope) {
|
||||
apiTokenSalt: generateASecret(),
|
||||
transferTokenSalt: generateASecret(),
|
||||
adminJwtToken: generateASecret(),
|
||||
encryptionKey: generateASecret(),
|
||||
database: {
|
||||
client: scope.database.client,
|
||||
connection: {
|
||||
|
@ -5,3 +5,4 @@ API_TOKEN_SALT=tobemodified
|
||||
ADMIN_JWT_SECRET=tobemodified
|
||||
TRANSFER_TOKEN_SALT=tobemodified
|
||||
JWT_SECRET=tobemodified
|
||||
ENCRYPTION_KEY=tobemodified
|
||||
|
@ -10,6 +10,9 @@ module.exports = ({ env }) => ({
|
||||
salt: env('TRANSFER_TOKEN_SALT'),
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
encryptionKey: env('ENCRYPTION_KEY'),
|
||||
},
|
||||
flags: {
|
||||
nps: env.bool('FLAG_NPS', true),
|
||||
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
||||
|
@ -5,3 +5,4 @@ API_TOKEN_SALT=tobemodified
|
||||
ADMIN_JWT_SECRET=tobemodified
|
||||
TRANSFER_TOKEN_SALT=tobemodified
|
||||
JWT_SECRET=tobemodified
|
||||
ENCRYPTION_KEY=tobemodified
|
||||
|
@ -10,6 +10,9 @@ export default ({ env }) => ({
|
||||
salt: env('TRANSFER_TOKEN_SALT'),
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
encryptionKey: env('ENCRYPTION_KEY'),
|
||||
},
|
||||
flags: {
|
||||
nps: env.bool('FLAG_NPS', true),
|
||||
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
||||
|
@ -5,3 +5,4 @@ API_TOKEN_SALT=tobemodified
|
||||
ADMIN_JWT_SECRET=tobemodified
|
||||
TRANSFER_TOKEN_SALT=tobemodified
|
||||
JWT_SECRET=tobemodified
|
||||
ENCRYPTION_KEY=tobemodified
|
||||
|
@ -5,3 +5,4 @@ API_TOKEN_SALT=tobemodified
|
||||
ADMIN_JWT_SECRET=tobemodified
|
||||
TRANSFER_TOKEN_SALT=tobemodified
|
||||
JWT_SECRET=tobemodified
|
||||
ENCRYPTION_KEY=tobemodified
|
||||
|
@ -10,6 +10,9 @@ export default ({ env }) => ({
|
||||
salt: env('TRANSFER_TOKEN_SALT'),
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
encryptionKey: env('ENCRYPTION_KEY'),
|
||||
},
|
||||
flags: {
|
||||
nps: env.bool('FLAG_NPS', true),
|
||||
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
||||
|
@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button, Dialog, Flex } from '@strapi/design-system';
|
||||
import { Check, ArrowClockwise } from '@strapi/icons';
|
||||
import { Button, Dialog, Flex, Tooltip } from '@strapi/design-system';
|
||||
import { Check, ArrowClockwise, Eye, EyeStriked } from '@strapi/icons';
|
||||
import { MessageDescriptor, useIntl } from 'react-intl';
|
||||
|
||||
import { ConfirmDialog } from '../../../../components/ConfirmDialog';
|
||||
@ -117,7 +117,10 @@ interface FormHeadProps<TToken extends Token | null> {
|
||||
token: TToken;
|
||||
canEditInputs: boolean;
|
||||
canRegenerate: boolean;
|
||||
canShowToken?: boolean;
|
||||
setToken: (token: TToken) => void;
|
||||
toggleToken?: () => void;
|
||||
showToken?: boolean;
|
||||
isSubmitting: boolean;
|
||||
regenerateUrl: string;
|
||||
}
|
||||
@ -126,6 +129,9 @@ export const FormHead = <TToken extends Token | null>({
|
||||
title,
|
||||
token,
|
||||
setToken,
|
||||
toggleToken,
|
||||
showToken,
|
||||
canShowToken,
|
||||
canEditInputs,
|
||||
canRegenerate,
|
||||
isSubmitting,
|
||||
@ -137,6 +143,7 @@ export const FormHead = <TToken extends Token | null>({
|
||||
...token,
|
||||
accessKey: newKey,
|
||||
});
|
||||
toggleToken?.();
|
||||
};
|
||||
|
||||
return (
|
||||
@ -151,6 +158,31 @@ export const FormHead = <TToken extends Token | null>({
|
||||
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
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
|
@ -64,10 +64,18 @@ export const TokenBox = ({ token, tokenType }: TokenBoxProps) => {
|
||||
}
|
||||
subtitle={
|
||||
token
|
||||
? formatMessage({
|
||||
id: 'Settings.tokens.copy.lastWarning',
|
||||
defaultMessage: 'Make sure to copy this token, you won’t be able to see it again!',
|
||||
})
|
||||
? formatMessage(
|
||||
tokenType === 'api-token'
|
||||
? {
|
||||
id: 'Settings.tokens.copy.subtitle',
|
||||
defaultMessage: 'Copy this token for use elsewhere',
|
||||
}
|
||||
: {
|
||||
id: 'Settings.tokens.copy.lastWarning',
|
||||
defaultMessage:
|
||||
'Make sure to copy this token, you won’t be able to see it again!',
|
||||
}
|
||||
)
|
||||
: formatMessage({
|
||||
id: 'Settings.tokens.copy.editMessage',
|
||||
defaultMessage: 'For security reasons, you can only see your token once.',
|
||||
|
@ -51,6 +51,8 @@ export const EditView = () => {
|
||||
}
|
||||
: null
|
||||
);
|
||||
const [showToken, setShowToken] = React.useState(Boolean(locationState?.apiToken?.accessKey));
|
||||
const hideTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const { trackUsage } = useTracking();
|
||||
const setCurrentStep = useGuidedTour('EditView', (state) => state.setCurrentStep);
|
||||
const {
|
||||
@ -69,7 +71,6 @@ export const EditView = () => {
|
||||
|
||||
const contentAPIPermissionsQuery = useGetPermissionsQuery();
|
||||
const contentAPIRoutesQuery = useGetRoutesQuery();
|
||||
|
||||
/**
|
||||
* Separate effects otherwise we could end
|
||||
* up duplicating the same notification.
|
||||
@ -173,6 +174,23 @@ export const EditView = () => {
|
||||
}
|
||||
}, [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 [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 = {
|
||||
...state,
|
||||
onChange: handleChangeCheckbox,
|
||||
@ -312,6 +338,7 @@ export const EditView = () => {
|
||||
};
|
||||
|
||||
const canEditInputs = (canUpdate && !isCreating) || (canCreate && isCreating);
|
||||
const canShowToken = !!apiToken?.accessKey;
|
||||
|
||||
if (isLoading) {
|
||||
return <Page.Loading />;
|
||||
@ -352,17 +379,21 @@ export const EditView = () => {
|
||||
}}
|
||||
token={apiToken}
|
||||
setToken={setApiToken}
|
||||
toggleToken={toggleToken}
|
||||
showToken={showToken}
|
||||
canEditInputs={canEditInputs}
|
||||
canRegenerate={canRegenerate}
|
||||
canShowToken={canShowToken}
|
||||
isSubmitting={isSubmitting}
|
||||
regenerateUrl="/admin/api-tokens/"
|
||||
/>
|
||||
|
||||
<Layouts.Content>
|
||||
<Flex direction="column" alignItems="stretch" gap={6}>
|
||||
{Boolean(apiToken?.name) && (
|
||||
<TokenBox token={apiToken?.accessKey} tokenType={API_TOKEN_TYPE} />
|
||||
{apiToken?.accessKey && showToken && (
|
||||
<TokenBox token={apiToken.accessKey} tokenType={API_TOKEN_TYPE} />
|
||||
)}
|
||||
|
||||
<FormApiTokenContainer
|
||||
errors={errors}
|
||||
onChange={handleChange}
|
||||
|
@ -263,6 +263,7 @@ const EditView = () => {
|
||||
}}
|
||||
token={transferToken}
|
||||
setToken={setTransferToken}
|
||||
canShowToken={false}
|
||||
canEditInputs={canEditInputs}
|
||||
canRegenerate={canRegenerate}
|
||||
isSubmitting={isSubmitting}
|
||||
|
@ -47,6 +47,13 @@ export default {
|
||||
required: true,
|
||||
searchable: false,
|
||||
},
|
||||
encryptedKey: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
configurable: false,
|
||||
required: false,
|
||||
searchable: false,
|
||||
},
|
||||
lastUsedAt: {
|
||||
type: 'datetime',
|
||||
configurable: false,
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
update as apiTokenUpdate,
|
||||
getByName,
|
||||
} from '../api-token';
|
||||
import encryptionService from '../encryption';
|
||||
|
||||
const getActionProvider = (actions = []) => {
|
||||
return {
|
||||
@ -26,6 +27,30 @@ describe('API Token', () => {
|
||||
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 nowSpy: any;
|
||||
|
||||
@ -48,16 +73,13 @@ describe('API Token', () => {
|
||||
test('Creates a new read-only token', async () => {
|
||||
const create = jest.fn(({ data }) => Promise.resolve(data));
|
||||
|
||||
global.strapi = {
|
||||
setupStrapiMock({
|
||||
db: {
|
||||
query() {
|
||||
return { create };
|
||||
},
|
||||
},
|
||||
config: {
|
||||
get: jest.fn(() => ''),
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
const attributes = {
|
||||
name: 'api-token_tests-name',
|
||||
@ -72,6 +94,7 @@ describe('API Token', () => {
|
||||
data: {
|
||||
...attributes,
|
||||
accessKey: hash(mockedApiToken.hexedString),
|
||||
encryptedKey: expect.any(String),
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
},
|
||||
@ -80,6 +103,7 @@ describe('API Token', () => {
|
||||
expect(res).toEqual({
|
||||
...attributes,
|
||||
accessKey: mockedApiToken.hexedString,
|
||||
encryptedKey: expect.any(String),
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
});
|
||||
@ -96,16 +120,13 @@ describe('API Token', () => {
|
||||
const expectedExpires = Date.now() + attributes.lifespan;
|
||||
|
||||
const create = jest.fn(({ data }) => Promise.resolve(data));
|
||||
global.strapi = {
|
||||
setupStrapiMock({
|
||||
db: {
|
||||
query() {
|
||||
return { create };
|
||||
},
|
||||
},
|
||||
config: {
|
||||
get: jest.fn(() => ''),
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
const res = await apiTokenCreate(attributes);
|
||||
|
||||
@ -114,6 +135,7 @@ describe('API Token', () => {
|
||||
data: {
|
||||
...attributes,
|
||||
accessKey: hash(mockedApiToken.hexedString),
|
||||
encryptedKey: expect.any(String),
|
||||
expiresAt: expectedExpires,
|
||||
lifespan: attributes.lifespan,
|
||||
},
|
||||
@ -122,6 +144,7 @@ describe('API Token', () => {
|
||||
expect(res).toEqual({
|
||||
...attributes,
|
||||
accessKey: mockedApiToken.hexedString,
|
||||
encryptedKey: expect.any(String),
|
||||
expiresAt: expectedExpires,
|
||||
lifespan: attributes.lifespan,
|
||||
});
|
||||
@ -137,16 +160,13 @@ describe('API Token', () => {
|
||||
} as any;
|
||||
|
||||
const create = jest.fn(({ data }) => Promise.resolve(data));
|
||||
global.strapi = {
|
||||
setupStrapiMock({
|
||||
db: {
|
||||
query() {
|
||||
return { create };
|
||||
},
|
||||
},
|
||||
config: {
|
||||
get: jest.fn(() => ''),
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
expect(async () => {
|
||||
await apiTokenCreate(attributes);
|
||||
@ -182,7 +202,7 @@ describe('API Token', () => {
|
||||
)
|
||||
);
|
||||
|
||||
global.strapi = {
|
||||
setupStrapiMock({
|
||||
...getActionProvider(['admin::content.content.read'] as any),
|
||||
db: {
|
||||
query() {
|
||||
@ -193,10 +213,7 @@ describe('API Token', () => {
|
||||
};
|
||||
},
|
||||
},
|
||||
config: {
|
||||
get: jest.fn(() => ''),
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
const res = await apiTokenCreate(attributes);
|
||||
|
||||
@ -213,6 +230,7 @@ describe('API Token', () => {
|
||||
data: {
|
||||
...omit('permissions', attributes),
|
||||
accessKey: hash(mockedApiToken.hexedString),
|
||||
encryptedKey: expect.any(String),
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
},
|
||||
@ -265,7 +283,7 @@ describe('API Token', () => {
|
||||
)
|
||||
);
|
||||
|
||||
global.strapi = {
|
||||
setupStrapiMock({
|
||||
...getActionProvider(['admin::content.content.read'] as any),
|
||||
db: {
|
||||
query() {
|
||||
@ -276,10 +294,7 @@ describe('API Token', () => {
|
||||
};
|
||||
},
|
||||
},
|
||||
config: {
|
||||
get: jest.fn(() => ''),
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
const res = await apiTokenCreate(attributes);
|
||||
|
||||
@ -297,6 +312,7 @@ describe('API Token', () => {
|
||||
data: {
|
||||
...omit('permissions', attributes),
|
||||
accessKey: hash(mockedApiToken.hexedString),
|
||||
encryptedKey: expect.any(String),
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
},
|
||||
@ -338,7 +354,7 @@ describe('API Token', () => {
|
||||
)
|
||||
);
|
||||
|
||||
global.strapi = {
|
||||
setupStrapiMock({
|
||||
...getActionProvider(['api::foo.foo.find', 'api::foo.foo.create'] as any),
|
||||
db: {
|
||||
query() {
|
||||
@ -349,10 +365,7 @@ describe('API Token', () => {
|
||||
};
|
||||
},
|
||||
},
|
||||
config: {
|
||||
get: jest.fn(() => ''),
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
const res = await apiTokenCreate(attributes);
|
||||
|
||||
@ -386,7 +399,7 @@ describe('API Token', () => {
|
||||
)
|
||||
);
|
||||
|
||||
global.strapi = {
|
||||
setupStrapiMock({
|
||||
...getActionProvider(['valid-permission'] as any),
|
||||
db: {
|
||||
query() {
|
||||
@ -396,10 +409,7 @@ describe('API Token', () => {
|
||||
};
|
||||
},
|
||||
},
|
||||
config: {
|
||||
get: jest.fn(() => ''),
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
await expect(() => apiTokenCreate(attributes)).rejects.toThrowError(
|
||||
new errors.ApplicationError(
|
||||
@ -426,6 +436,15 @@ describe('API Token', () => {
|
||||
},
|
||||
} as any;
|
||||
|
||||
setupStrapiMock({
|
||||
config: {
|
||||
get: jest.fn(() => ({
|
||||
admin: { apiToken: { salt: 'api-token_tests-salt' } },
|
||||
})),
|
||||
set: mockedConfigSet,
|
||||
},
|
||||
});
|
||||
|
||||
checkSaltIsDefined();
|
||||
|
||||
expect(mockedAppendFile).not.toHaveBeenCalled();
|
||||
@ -433,11 +452,11 @@ describe('API Token', () => {
|
||||
});
|
||||
|
||||
test('It throws if the salt is not defined', () => {
|
||||
global.strapi = {
|
||||
setupStrapiMock({
|
||||
config: {
|
||||
get: jest.fn(() => null),
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
try {
|
||||
checkSaltIsDefined();
|
||||
@ -611,17 +630,13 @@ describe('API Token', () => {
|
||||
test('It regenerates the accessKey', async () => {
|
||||
const update = jest.fn(({ data }) => Promise.resolve(data));
|
||||
|
||||
global.strapi = {
|
||||
setupStrapiMock({
|
||||
db: {
|
||||
query() {
|
||||
return { update };
|
||||
},
|
||||
},
|
||||
config: {
|
||||
get: jest.fn(() => ''),
|
||||
},
|
||||
} as any;
|
||||
|
||||
});
|
||||
const id = 1;
|
||||
const res = await regenerate(id);
|
||||
|
||||
@ -630,24 +645,25 @@ describe('API Token', () => {
|
||||
select: ['id', 'accessKey'],
|
||||
data: {
|
||||
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 () => {
|
||||
const update = jest.fn(() => Promise.resolve(null));
|
||||
|
||||
global.strapi = {
|
||||
setupStrapiMock({
|
||||
db: {
|
||||
query() {
|
||||
return { update };
|
||||
},
|
||||
},
|
||||
config: {
|
||||
get: jest.fn(() => ''),
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
const id = 1;
|
||||
await expect(async () => {
|
||||
@ -659,6 +675,7 @@ describe('API Token', () => {
|
||||
select: ['id', 'accessKey'],
|
||||
data: {
|
||||
accessKey: hash(mockedApiToken.hexedString),
|
||||
encryptedKey: expect.any(String),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@ -3,6 +3,7 @@ import { omit, difference, isNil, isEmpty, map, isArray, uniq, isNumber } from '
|
||||
import { errors } from '@strapi/utils';
|
||||
import type { Update, ApiToken, ApiTokenBody } from '../../../shared/contracts/api-token';
|
||||
import constants from './constants';
|
||||
import { getService } from '../utils';
|
||||
|
||||
const { ValidationError, NotFoundError } = errors;
|
||||
|
||||
@ -117,15 +118,28 @@ const getBy = async (whereParams: WhereParams = {}): Promise<ApiToken | null> =>
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = await strapi.db
|
||||
.query('admin::api-token')
|
||||
.findOne({ select: SELECT_FIELDS, populate: POPULATE_FIELDS, where: whereParams });
|
||||
const token = await strapi.db.query('admin::api-token').findOne({
|
||||
select: [...SELECT_FIELDS, 'encryptedKey'],
|
||||
populate: POPULATE_FIELDS,
|
||||
where: whereParams,
|
||||
});
|
||||
|
||||
if (!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
|
||||
*/
|
||||
const create = async (attributes: ApiTokenBody): Promise<ApiToken> => {
|
||||
const encryptionService = getService('encryption');
|
||||
const accessKey = crypto.randomBytes(128).toString('hex');
|
||||
const encryptedKey = encryptionService.encrypt(accessKey);
|
||||
|
||||
assertCustomTokenPermissionsValidity(attributes.type, attributes.permissions);
|
||||
assertValidLifespan(attributes.lifespan);
|
||||
@ -176,6 +192,7 @@ const create = async (attributes: ApiTokenBody): Promise<ApiToken> => {
|
||||
data: {
|
||||
...omit('permissions', attributes),
|
||||
accessKey: hash(accessKey),
|
||||
encryptedKey,
|
||||
...getExpirationFields(attributes.lifespan),
|
||||
},
|
||||
});
|
||||
@ -211,12 +228,15 @@ const create = async (attributes: ApiTokenBody): Promise<ApiToken> => {
|
||||
|
||||
const regenerate = async (id: string | number): Promise<ApiToken> => {
|
||||
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({
|
||||
select: ['id', 'accessKey'],
|
||||
where: { id },
|
||||
data: {
|
||||
accessKey: hash(accessKey),
|
||||
encryptedKey,
|
||||
},
|
||||
});
|
||||
|
||||
|
77
packages/core/admin/server/src/services/encryption.ts
Normal file
77
packages/core/admin/server/src/services/encryption.ts
Normal 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,
|
||||
};
|
@ -4,6 +4,7 @@ import user from './user';
|
||||
import role from './role';
|
||||
import passport from './passport';
|
||||
import metrics from './metrics';
|
||||
import encryption from './encryption';
|
||||
import * as token from './token';
|
||||
import * as permission from './permission';
|
||||
import * as contentType from './content-type';
|
||||
@ -30,4 +31,5 @@ export default {
|
||||
'api-token': apiToken,
|
||||
transfer,
|
||||
'project-settings': projectSettings,
|
||||
encryption,
|
||||
};
|
||||
|
@ -3,6 +3,7 @@ import role from '../services/role';
|
||||
import user from '../services/user';
|
||||
import passport from '../services/passport';
|
||||
import metrics from '../services/metrics';
|
||||
import encryption from '../services/encryption';
|
||||
import * as permission from '../services/permission';
|
||||
import * as contentType from '../services/content-type';
|
||||
import * as token from '../services/token';
|
||||
@ -22,6 +23,7 @@ type S = {
|
||||
'api-token': typeof apiToken;
|
||||
'project-settings': typeof projectSettings;
|
||||
transfer: typeof transfer;
|
||||
encryption: typeof encryption;
|
||||
};
|
||||
|
||||
type Resolve<T> = T extends (...args: unknown[]) => unknown ? T : { [K in keyof T]: T[K] };
|
||||
|
@ -3,6 +3,7 @@ import type { Data } from '@strapi/types';
|
||||
|
||||
export type ApiToken = {
|
||||
accessKey: string;
|
||||
encryptedKey: string;
|
||||
createdAt: string;
|
||||
description: string;
|
||||
expiresAt: string;
|
||||
|
@ -15,7 +15,7 @@ const createAPIToken = async (page, tokenName, duration, type) => {
|
||||
await page.getByRole('option', { name: type }).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();
|
||||
};
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user