feat: Make security tokens optional (#1022)

Security tokens are a great way to protect sensitive data from being stored in plain text within your project file, but at the same time can be more difficult to use when sharing projects with team members.

In this feature security tokens are still enabled by default but give the project author the ability to turn off security tokens which then stores all provider options in plain text.
Esse commit está contido em:
Wallace Breza
2020-11-03 11:34:17 -08:00
commit de GitHub
commit f731dfaece
22 arquivos alterados com 166 adições e 51 exclusões
+3
Ver Arquivo
@@ -5,6 +5,9 @@ pr:
- dev* # kick off for pr targeting dev or prefix dev
- master # trigger build for pr targeting master
variables:
- group: CODE_COV
jobs:
- job: Linux
pool:
+1 -1
Ver Arquivo
@@ -4,4 +4,4 @@ const common = require('./webpack.common.js')
module.exports = merge(common, {
mode: 'development',
devtool: "inline-source-map",
})
})
+4
Ver Arquivo
@@ -84,6 +84,10 @@ export const english: IAppStrings = {
title: "Security Token",
description: "Used to encrypt sensitive data within project files",
},
useSecurityToken: {
title: "Use Security Token",
description: "When enabled will encrypt sensitive data within provider configuration",
},
save: "Save Project",
sourceConnection: {
title: "Source Connection",
+4
Ver Arquivo
@@ -85,6 +85,10 @@ export const spanish: IAppStrings = {
title: "Token de seguridad",
description: "Se utiliza para cifrar datos confidenciales dentro de archivos de proyecto",
},
useSecurityToken: {
title: "Usar Token de Seguridad",
description: "Si está habilitado, los datos confidenciales se cifrarán",
},
save: "Guardar el Proyecto",
sourceConnection: {
title: "Conexión de Origen",
+5
Ver Arquivo
@@ -86,6 +86,11 @@ export const japanese: IAppStrings = {
title: "セキュリティ トークン", // Security Token,
description: "プロジェクト ファイル内の機密データを暗号化するために使用されます", // Used to encrypt sensitive data within project file"
},
useSecurityToken: {
title: "セキュリティ トークン", // Use Security Token
description: "有効にすると、プロバイダー構成内の機密データが暗号化されます。",
// When enabled will encrypt sensitive data within provider configuration
},
save: "プロジェクトを保存", // Save Project,
sourceConnection: {
title: "ソース接続", // Source Connection,
+5
Ver Arquivo
@@ -86,6 +86,11 @@ export const korean: IAppStrings = {
title: "보안 토큰", // Security Token,
description: "프로젝트 파일 내에서 중요한 데이터를 암호화하는 데 사용", // Used to encrypt sensitive data within project file
},
useSecurityToken: {
title: "보안 토큰 사용", // Use Security Token
description: "활성화되면 공급자 구성 내에서 중요한 데이터를 암호화합니다.",
// When enabled will encrypt sensitive data within provider configuration
},
save: "프로젝트 저장", // Save Project,
sourceConnection: {
title: "소스 연결", // Source Connection,
+5
Ver Arquivo
@@ -86,6 +86,11 @@ export const chinese: IAppStrings = {
title: "安全令牌", // Security Token
description: "用于加密项目文件中的敏感数据", // Used to encrypt sensitive data within project files
},
useSecurityToken: {
title: "使用安全令牌", // Use Security Token
description: "启用后将在提供者配置内加密敏感数据",
// When enabled will encrypt sensitive data within provider configuration
},
save: "保存项目", // Save Project
sourceConnection: {
title: "源连接", // Source Connection
+5
Ver Arquivo
@@ -86,6 +86,11 @@ export const chinesetw: IAppStrings = {
title: "安全性權杖", // Security Token
description: "用於加密專案檔案中的敏感資料", // Used to encrypt sensitive data within project files
},
useSecurityToken: {
title: "使用安全令牌", // Use Security Token
description: "啟用後將在提供者配置內加密敏感數據",
// When enabled will encrypt sensitive data within provider configuration
},
save: "保存專案", // Save Project
sourceConnection: {
title: "來源連線", // Source Connection
+1
Ver Arquivo
@@ -277,6 +277,7 @@ export default class MockFactory {
id: `project-${name}`,
name: `Project ${name}`,
version: appInfo.version,
useSecurityToken: true,
securityToken: `Security-Token-${name}`,
assets: {},
exportFormat: MockFactory.exportFormat(),
+4
Ver Arquivo
@@ -90,6 +90,10 @@ export interface IAppStrings {
title: string;
description: string;
},
useSecurityToken: {
title: string;
description: string;
},
save: string;
sourceConnection: {
title: string;
+1 -1
Ver Arquivo
@@ -59,7 +59,7 @@ export function encodeFileURI(path: string, additionalEncodings?: boolean): stri
const encodings = {
"\#": "%23",
"\?": "%3F",
};
};
const encodedURI = `file:${encodeURI(normalizeSlashes(path))}`;
if (additionalEncodings) {
return encodedURI.replace(matchString, (match) => encodings[match]);
+2 -1
Ver Arquivo
@@ -107,7 +107,8 @@ export interface IProject {
id: string;
name: string;
version: string;
securityToken: string;
useSecurityToken: boolean;
securityToken?: string;
description?: string;
tags: ITag[];
sourceConnection: IConnection;
@@ -6,11 +6,6 @@
"type": "string",
"pattern": "^[^\\\\\\\\/:*?\\\\\\\"<>|]*$"
},
"securityToken": {
"title": "${strings.projectSettings.securityToken.title}",
"description": "${strings.projectSettings.securityToken.description}",
"type": "string"
},
"sourceConnection": {
"title": "${strings.projectSettings.sourceConnection.title}",
"description": "${strings.projectSettings.sourceConnection.description}",
@@ -44,6 +39,41 @@
"tags": {
"title": "${strings.tags.title}",
"type": "array"
},
"useSecurityToken": {
"title": "${strings.projectSettings.useSecurityToken.title}",
"description": "${strings.projectSettings.useSecurityToken.description}",
"type": "boolean",
"default": true
}
},
"dependencies": {
"useSecurityToken": {
"oneOf": [
{
"properties": {
"useSecurityToken": {
"enum": [
true
]
},
"securityToken": {
"title": "${strings.projectSettings.securityToken.title}",
"description": "${strings.projectSettings.securityToken.description}",
"type": "string"
}
}
},
{
"properties": {
"useSecurityToken": {
"enum": [
false
]
}
}
}
]
}
},
"required": [
@@ -263,8 +263,8 @@ describe("Project Form Component", () => {
onCancel: onCancelHandler,
});
const newTagName = "My new tag";
wrapper.find("input").last().simulate("change", { target: { value: newTagName } });
wrapper.find("input").last().simulate("keyDown", { keyCode: 13 });
wrapper.find("input#tagInputField").last().simulate("change", { target: { value: newTagName } });
wrapper.find("input#tagInputField").last().simulate("keyDown", { keyCode: 13 });
const tags = wrapper.state().formData.tags;
expect(tags).toHaveLength(1);
@@ -5,10 +5,11 @@ import { addLocValues, strings } from "../../../../common/strings";
import { IConnection, IProject, ITag, IAppSettings } from "../../../../models/applicationState";
import { StorageProviderFactory } from "../../../../providers/storage/storageProviderFactory";
import { ConnectionPickerWithRouter } from "../../common/connectionPicker/connectionPicker";
import { CustomField } from "../../common/customField/customField";
import { CustomField, CustomWidget } from "../../common/customField/customField";
import CustomFieldTemplate from "../../common/customField/customFieldTemplate";
import { ISecurityTokenPickerProps, SecurityTokenPicker } from "../../common/securityTokenPicker/securityTokenPicker";
import "vott-react/dist/css/tagsInput.css";
import Checkbox from "rc-checkbox";
import { IConnectionProviderPickerProps } from "../../common/connectionProviderPicker/connectionProviderPicker";
import LocalFolderPicker from "../../common/localFolderPicker/localFolderPicker";
@@ -54,6 +55,11 @@ export interface IProjectFormState {
export default class ProjectForm extends React.Component<IProjectFormProps, IProjectFormState> {
private widgets = {
localFolderPicker: (LocalFolderPicker as any) as Widget,
checkbox: CustomWidget(Checkbox, (props) => ({
checked: props.value,
onChange: (value) => props.onChange(value.target.checked),
disabled: props.disabled,
})),
};
private tagsInput: React.RefObject<TagsInput>;
@@ -2,6 +2,9 @@
"securityToken": {
"ui:field": "securityToken"
},
"useSecurityToken": {
"ui:widget": "checkbox"
},
"description": {
"ui:widget": "textarea"
},
@@ -60,6 +60,7 @@ describe("Project settings page", () => {
const store = createReduxStore(MockFactory.initialState());
const props = MockFactory.projectSettingsProps();
const saveProjectSpy = jest.spyOn(props.projectActions, "saveProject");
const ensureSecurityTokenSpy = jest.spyOn(props.applicationActions, "ensureSecurityToken");
projectServiceMock.prototype.save = jest.fn((project) => Promise.resolve(project));
@@ -68,6 +69,7 @@ describe("Project settings page", () => {
await MockFactory.flushUi();
expect(saveProjectSpy).toBeCalled();
expect(ensureSecurityTokenSpy).toBeCalled();
});
it("Throws an error when a user tries to create a duplicate project", async () => {
@@ -114,6 +116,7 @@ describe("Project settings page", () => {
const store = createReduxStore(initialState);
const props = MockFactory.projectSettingsProps();
const saveProjectSpy = jest.spyOn(props.projectActions, "saveProject");
const ensureSecurityTokenSpy = jest.spyOn(props.applicationActions, "ensureSecurityToken");
const saveAppSettingsSpy = jest.spyOn(props.applicationActions, "saveAppSettings");
projectServiceMock.prototype.save = jest.fn((project) => Promise.resolve(project));
@@ -133,6 +136,11 @@ describe("Project settings page", () => {
securityToken: `${project.name} Token`,
});
expect(ensureSecurityTokenSpy).toBeCalledWith({
...project,
securityToken: `${project.name} Token`,
});
expect(localStorage.removeItem).toBeCalledWith("projectForm");
});
@@ -47,7 +47,7 @@ function mapDispatchToProps(dispatch) {
};
}
const projectFormTempKey = "projectForm";
const projectFormKey = "projectForm";
/**
* @name - Project Settings Page
@@ -64,14 +64,17 @@ export default class ProjectSettingsPage extends React.Component<IProjectSetting
// If we are creating a new project check to see if there is a partial
// project already created in local storage
if (this.props.match.url === "/projects/create") {
const projectJson = localStorage.getItem(projectFormTempKey);
const projectJson = localStorage.getItem(projectFormKey);
if (projectJson) {
this.setState({ project: JSON.parse(projectJson) });
}
} else if (!this.props.project && projectId) {
const projectToLoad = this.props.recentProjects.find((project) => project.id === projectId);
if (projectToLoad) {
await this.props.applicationActions.ensureSecurityToken(projectToLoad);
if (projectToLoad.useSecurityToken) {
await this.props.applicationActions.ensureSecurityToken(projectToLoad);
}
await this.props.projectActions.loadProject(projectToLoad);
}
}
@@ -119,16 +122,19 @@ export default class ProjectSettingsPage extends React.Component<IProjectSetting
*/
private onFormChange = (project: IProject) => {
if (this.isPartialProject(project)) {
localStorage.setItem(projectFormTempKey, JSON.stringify(project));
localStorage.setItem(projectFormKey, JSON.stringify(project));
}
}
private onFormSubmit = async (project: IProject) => {
const isNew = !(!!project.id);
await this.props.applicationActions.ensureSecurityToken(project);
if (project.useSecurityToken) {
await this.props.applicationActions.ensureSecurityToken(project);
}
await this.props.projectActions.saveProject(project);
localStorage.removeItem(projectFormTempKey);
localStorage.removeItem(projectFormKey);
toast.success(interpolate(strings.projectSettings.messages.saveSuccess, { project }));
@@ -140,7 +146,7 @@ export default class ProjectSettingsPage extends React.Component<IProjectSetting
}
private onFormCancel = () => {
localStorage.removeItem(projectFormTempKey);
localStorage.removeItem(projectFormKey);
this.props.history.goBack();
}
+4 -6
Ver Arquivo
@@ -12,11 +12,9 @@ import {
IProject,
} from "../../models/applicationState";
import { createAction, createPayloadAction, IPayloadAction } from "./actionCreators";
import { ExportAssetState, IExportResults } from "../../providers/export/exportProvider";
import { IExportResults } from "../../providers/export/exportProvider";
import { appInfo } from "../../common/appInfo";
import { strings } from "../../common/strings";
import { IExportFormat } from "vott-react";
import { IVottJsonExportProviderOptions } from "../../providers/export/vottJson";
/**
* Actions to be performed in relation to projects
@@ -48,7 +46,7 @@ export function loadProject(project: IProject):
const projectToken = appState.appSettings.securityTokens
.find((securityToken) => securityToken.name === project.securityToken);
if (!projectToken) {
if (project.useSecurityToken && !projectToken) {
throw new AppError(ErrorCode.SecurityTokenNotFound, "Security Token Not Found");
}
const loadedProject = await projectService.load(project, projectToken);
@@ -76,7 +74,7 @@ export function saveProject(project: IProject)
const projectToken = appState.appSettings.securityTokens
.find((securityToken) => securityToken.name === project.securityToken);
if (!projectToken) {
if (project.useSecurityToken && !projectToken) {
throw new AppError(ErrorCode.SecurityTokenNotFound, "Security Token Not Found");
}
@@ -104,7 +102,7 @@ export function deleteProject(project: IProject)
const projectToken = appState.appSettings.securityTokens
.find((securityToken) => securityToken.name === project.securityToken);
if (!projectToken) {
if (project.useSecurityToken && !projectToken) {
throw new AppError(ErrorCode.SecurityTokenNotFound, "Security Token Not Found");
}
+1
Ver Arquivo
@@ -57,6 +57,7 @@ export default class ImportService implements IImportService {
id: shortid.generate(),
name: projectInfo.file.name.split(".")[0],
version: packageJson.version,
useSecurityToken: true,
securityToken: `${projectInfo.file.name.split(".")[0]} Token`,
description: "Converted V1 Project",
tags: parsedTags,
+43 -21
Ver Arquivo
@@ -9,13 +9,13 @@ import {
import { constants } from "../common/constants";
import { ExportProviderFactory } from "../providers/export/exportProviderFactory";
import { generateKey } from "../common/crypto";
import { encryptProject, decryptProject } from "../common/utils";
import * as utils from "../common/utils";
import { ExportAssetState } from "../providers/export/exportProvider";
import { IVottJsonExportProviderOptions } from "../providers/export/vottJson";
import { IPascalVOCExportProviderOptions } from "../providers/export/pascalVOC";
describe("Project Service", () => {
let projectSerivce: IProjectService = null;
let projectService: IProjectService = null;
let testProject: IProject = null;
let projectList: IProject[] = null;
let securityToken: ISecurityToken = null;
@@ -33,27 +33,41 @@ describe("Project Service", () => {
StorageProviderFactory.create = jest.fn(() => storageProviderMock);
ExportProviderFactory.create = jest.fn(() => exportProviderMock);
const encryptSpy = jest.spyOn(utils, "encryptProject");
const decryptSpy = jest.spyOn(utils, "decryptProject");
beforeEach(() => {
securityToken = {
name: "TestToken",
key: generateKey(),
};
testProject = MockFactory.createTestProject("TestProject");
projectSerivce = new ProjectService();
projectService = new ProjectService();
storageProviderMock.writeText.mockClear();
storageProviderMock.deleteFile.mockClear();
encryptSpy.mockClear();
decryptSpy.mockClear();
});
it("Load decrypts any project settings using the specified key", async () => {
const encryptedProject = encryptProject(testProject, securityToken);
const decryptedProject = await projectSerivce.load(encryptedProject, securityToken);
const encryptedProject = utils.encryptProject(testProject, securityToken);
const decryptedProject = await projectService.load(encryptedProject, securityToken);
expect(decryptedProject).toEqual(testProject);
expect(decryptSpy).toBeCalledWith(encryptedProject, securityToken);
});
it("Does not decrypt project when a security token is not in use", async () => {
const project: IProject = { ...testProject, useSecurityToken: false };
const loadedProject = await projectService.load(project);
expect(loadedProject).toEqual(project);
expect(decryptSpy).not.toBeCalled();
});
it("Saves calls project storage provider to write project", async () => {
const result = await projectSerivce.save(testProject, securityToken);
const result = await projectService.save(testProject, securityToken);
const encryptedProject: IProject = {
...testProject,
@@ -72,6 +86,7 @@ describe("Project Service", () => {
};
expect(result).toEqual(encryptedProject);
expect(encryptSpy).toBeCalledWith(testProject, securityToken);
expect(StorageProviderFactory.create).toBeCalledWith(
testProject.targetConnection.providerType,
testProject.targetConnection.providerOptions,
@@ -82,9 +97,16 @@ describe("Project Service", () => {
expect.any(String));
});
it("Does not encrypt project during save when a security token is not in use", async () => {
const projectToSave: IProject = { ...testProject, useSecurityToken: false };
await projectService.save(projectToSave);
expect(encryptSpy).not.toBeCalled();
});
it("sets default export settings when not defined", async () => {
testProject.exportFormat = null;
const result = await projectSerivce.save(testProject, securityToken);
const result = await projectService.save(testProject, securityToken);
const vottJsonExportProviderOptions: IVottJsonExportProviderOptions = {
assetState: ExportAssetState.Visited,
@@ -96,14 +118,14 @@ describe("Project Service", () => {
providerOptions: vottJsonExportProviderOptions,
};
const decryptedProject = decryptProject(result, securityToken);
const decryptedProject = utils.decryptProject(result, securityToken);
expect(decryptedProject.exportFormat).toEqual(expectedExportFormat);
});
it("sets default active learning setting when not defined", async () => {
testProject.activeLearningSettings = null;
const result = await projectSerivce.save(testProject, securityToken);
const result = await projectService.save(testProject, securityToken);
const activeLearningSettings: IActiveLearningSettings = {
autoDetect: false,
@@ -116,7 +138,7 @@ describe("Project Service", () => {
it("initializes tags to empty array if not defined", async () => {
testProject.tags = null;
const result = await projectSerivce.save(testProject, securityToken);
const result = await projectService.save(testProject, securityToken);
expect(result.tags).toEqual([]);
});
@@ -127,7 +149,7 @@ describe("Project Service", () => {
providerOptions: null,
};
await projectSerivce.save(testProject, securityToken);
await projectService.save(testProject, securityToken);
expect(ExportProviderFactory.create).toBeCalledWith(
testProject.exportFormat.providerType,
@@ -140,7 +162,7 @@ describe("Project Service", () => {
it("Save throws error if writing to storage provider fails", async () => {
const expectedError = "Error writing to storage provider";
storageProviderMock.writeText.mockImplementationOnce(() => Promise.reject(expectedError));
await expect(projectSerivce.save(testProject, securityToken)).rejects.toEqual(expectedError);
await expect(projectService.save(testProject, securityToken)).rejects.toEqual(expectedError);
});
it("Save throws error if storage provider cannot be created", async () => {
@@ -148,11 +170,11 @@ describe("Project Service", () => {
const createMock = StorageProviderFactory.create as jest.Mock;
createMock.mockImplementationOnce(() => { throw expectedError; });
await expect(projectSerivce.save(testProject, securityToken)).rejects.toEqual(expectedError);
await expect(projectService.save(testProject, securityToken)).rejects.toEqual(expectedError);
});
it("Delete calls project storage provider to delete project", async () => {
await projectSerivce.delete(testProject);
await projectService.delete(testProject);
expect(StorageProviderFactory.create).toBeCalledWith(
testProject.targetConnection.providerType,
@@ -167,7 +189,7 @@ describe("Project Service", () => {
storageProviderMock.deleteFile
.mockImplementationOnce(() => Promise.reject(expectedError));
await expect(projectSerivce.delete(testProject)).rejects.toEqual(expectedError);
await expect(projectService.delete(testProject)).rejects.toEqual(expectedError);
});
it("Delete call fails if storage provider cannot be created", async () => {
@@ -175,20 +197,20 @@ describe("Project Service", () => {
const createMock = StorageProviderFactory.create as jest.Mock;
createMock.mockImplementationOnce(() => { throw expectedError; });
await expect(projectSerivce.delete(testProject)).rejects.toEqual(expectedError);
await expect(projectService.delete(testProject)).rejects.toEqual(expectedError);
});
it("isDuplicate returns false when called with a unique project", async () => {
testProject = MockFactory.createTestProject("TestProject");
projectList = MockFactory.createTestProjects();
expect(projectSerivce.isDuplicate(testProject, projectList)).toEqual(false);
expect(projectService.isDuplicate(testProject, projectList)).toEqual(false);
});
it("isDuplicate returns true when called with a duplicate project", async () => {
testProject = MockFactory.createTestProject("1");
testProject.id = undefined;
projectList = MockFactory.createTestProjects();
expect(projectSerivce.isDuplicate(testProject, projectList)).toEqual(true);
expect(projectService.isDuplicate(testProject, projectList)).toEqual(true);
});
it("deletes all asset metadata files when project is deleted", async () => {
@@ -199,7 +221,7 @@ describe("Project Service", () => {
testProject.assets = _.keyBy(assets, (asset) => asset.id);
await projectSerivce.delete(testProject);
await projectService.delete(testProject);
expect(storageProviderMock.deleteFile.mock.calls).toHaveLength(assets.length + 1);
});
@@ -215,8 +237,8 @@ describe("Project Service", () => {
} as IPascalVOCExportProviderOptions,
};
const encryptedProject = encryptProject(testProject, securityToken);
const decryptedProject = await projectSerivce.load(encryptedProject, securityToken);
const encryptedProject = utils.encryptProject(testProject, securityToken);
const decryptedProject = await projectService.load(encryptedProject, securityToken);
expect(decryptedProject.exportFormat.providerType).toEqual("pascalVOC");
expect(decryptedProject.exportFormat.providerOptions).toEqual(testProject.exportFormat.providerOptions);
+10 -6
Ver Arquivo
@@ -19,8 +19,8 @@ import { IExportFormat } from "vott-react";
* @member delete - Delete a project
*/
export interface IProjectService {
load(project: IProject, securityToken: ISecurityToken): Promise<IProject>;
save(project: IProject, securityToken: ISecurityToken): Promise<IProject>;
load(project: IProject, securityToken?: ISecurityToken): Promise<IProject>;
save(project: IProject, securityToken?: ISecurityToken): Promise<IProject>;
delete(project: IProject): Promise<void>;
isDuplicate(project: IProject, projectList: IProject[]): boolean;
}
@@ -49,11 +49,13 @@ export default class ProjectService implements IProjectService {
* @param project The project JSON to load
* @param securityToken The security token used to decrypt sensitive project settings
*/
public load(project: IProject, securityToken: ISecurityToken): Promise<IProject> {
public load(project: IProject, securityToken?: ISecurityToken): Promise<IProject> {
Guard.null(project);
try {
const loadedProject = decryptProject(project, securityToken);
const loadedProject = project.useSecurityToken
? decryptProject(project, securityToken)
: { ...project };
// Ensure tags is always initialized to an array
if (!loadedProject.tags) {
@@ -84,7 +86,7 @@ export default class ProjectService implements IProjectService {
* @param project - Project to save
* @param securityToken - Security Token to encrypt
*/
public async save(project: IProject, securityToken: ISecurityToken): Promise<IProject> {
public async save(project: IProject, securityToken?: ISecurityToken): Promise<IProject> {
Guard.null(project);
if (!project.id) {
@@ -110,7 +112,9 @@ export default class ProjectService implements IProjectService {
const storageProvider = StorageProviderFactory.createFromConnection(project.targetConnection);
await this.saveExportSettings(project);
project = encryptProject(project, securityToken);
project = project.useSecurityToken
? encryptProject(project, securityToken)
: { ...project };
await storageProvider.writeText(
`${project.name}${constants.projectFileExtension}`,