Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

bacpop-110 Persist Microreact tokens #41

Closed
wants to merge 13 commits into from
6 changes: 4 additions & 2 deletions app/client/src/components/GenerateMicroreactURL.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<div>
<button
v-if='!microreactToken'
id="generate-microreact-url-no-token-btn"
@click='showModal()'
class='btn btn-block btn-standard btn-download'
>
Expand All @@ -11,6 +12,7 @@

<button
v-if='tokenAvailable'
id="generate-microreact-url-token-btn"
@click='buildMicroreactURL({cluster, token: microreactToken})'
class='btn btn-block btn-standard btn-download'
>
Expand Down Expand Up @@ -55,9 +57,9 @@
</button>
</template>
<template v-else v-slot:body>
<p>You have not submitted a your Microreact token yet.</p>
<p>You have not submitted your Microreact token yet.</p>
<p>
This is needed to generate a microreact URL for you.<br />
This is needed to generate a Microreact URL for you.<br />
You can find your token in your
<a href='https://microreact.org/my-account/settings' target='_blank'>
Microreact Account Settings</a
Expand Down
32 changes: 29 additions & 3 deletions app/client/src/store/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,14 @@ export default {
.get<Versions>(`${serverUrl}/version`);
},
async getUser(context: ActionContext<RootState, RootState>) {
await api(context)
const { state, dispatch } = context;
const response = await api(context)
.withSuccess("setUser")
.withError("addError")
.get<User>(`${serverUrl}/user`);
if (response && !state.microreactToken) {
dispatch("getMicroreactToken");
}
},
async newProject(context: ActionContext<RootState, RootState>, name: string) {
const { commit, state } = context;
Expand Down Expand Up @@ -235,8 +239,13 @@ export default {
context: ActionContext<RootState, RootState>,
data: Record<string, string | number>
) {
const { state, commit } = context;
commit("setToken", data.token);
const { state, commit, dispatch } = context;

if (data.token !== state.microreactToken) {
commit("setToken", data.token);
dispatch("persistMicroreactToken", data.token);
}

await api(context)
.withSuccess("addMicroreactURL")
.withError("addError")
Expand All @@ -246,6 +255,23 @@ export default {
apiToken: state.microreactToken
});
},
async persistMicroreactToken(
context: ActionContext<RootState, RootState>,
token: string
) {
await api(context)
.ignoreSuccess()
.withError("addError")
.post<string>(`${serverUrl}/microreactToken`, { token });
},
async getMicroreactToken(
context: ActionContext<RootState, RootState>
) {
await api(context)
.withSuccess("setToken")
.withError("addError")
.get(`${serverUrl}/microreactToken`);
},
async getGraphml(
context: ActionContext<RootState, RootState>,
cluster: string | number
Expand Down
3 changes: 2 additions & 1 deletion app/client/src/store/getters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export const getters: BeebopGetters & GetterTree<RootState, RootState> = {

[BeebopGetter.checkProjectName]: (
state: RootState
): ((name: string, oldName?: string) => ProjectNameCheckResult) => {
): ((name: string, oldName?: string) => ProjectNameCheckResult
) => {
return (name: string, oldName?: string) => {
if (name.trim() === "") {
return ProjectNameCheckResult.Empty;
Expand Down
17 changes: 11 additions & 6 deletions app/client/tests/e2e/LoggedIn.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,18 @@ test.describe("Logged in Tests", () => {
// Expect download buttons and button to generate microreact URL to appear
await expectDownloadButtons("6930_8_13.fa", page);

// on clicking Generate Microreact URL button, modal appears
// Token will be saved for user after first run of the test
const tokenAvailable = await page.isVisible("#generate-microreact-url-token-btn");
await page.click("text=Generate Microreact URL");
await expect(page.locator(".modalFlex")).toContainText("No token submitted yet");
await expect(page.locator(".modalFlex .btn")).toContainText("Save token");
// after submitting microreact token, button turns into link to microreact.org
await page.locator("input").fill(process.env.MICROREACT_TOKEN as string);
await page.click("text=Save token");

if (!tokenAvailable) {
await expect(page.locator(".modalFlex")).toContainText("No token submitted yet");
await expect(page.locator(".modalFlex .btn")).toContainText("Save token");
// after submitting microreact token, button turns into link to microreact.org
await page.locator("input").fill(process.env.MICROREACT_TOKEN as string);
await page.click("text=Save token");
}

await expect(page.locator('tr:has-text("6930_8_13.fa") a')).toContainText("Visit Microreact URL");
await expect(page.locator('tr:has-text("6930_8_13.fa") a'))
.toHaveAttribute("href", /https:\/\/microreact.org\/project\/.*-poppunk.*/);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import EditProjectName from "@/components/projects/EditProjectName.vue";
import ProjectNameCheckMessage from "@/components/projects/ProjectNameCheckMessage.vue";
import { ProjectNameCheckResult } from "@/types";
import { getters } from "@/store/getters";
import { nextTick } from "vue";
import { mockRootState } from "../../../mocks";
import {nextTick} from "vue";

describe("EditProjectName", () => {
const mockRenameProject = jest.fn();
Expand Down Expand Up @@ -127,7 +127,8 @@ describe("EditProjectName", () => {
const expectComponentValuesForInputText = async (
inputText: string,
checkResult: ProjectNameCheckResult,
saveButtonEnabled: boolean) => {
saveButtonEnabled: boolean
) => {
await wrapper.find("input").setValue(inputText);
expect(wrapper.findComponent(ProjectNameCheckMessage).props("checkResult")).toBe(checkResult);
expect((wrapper.find("button#save-project-name").element as HTMLButtonElement).disabled)
Expand Down
95 changes: 88 additions & 7 deletions app/client/tests/unit/store/actions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ function responseError(error: BeebopError) {
};
}

const failureResponse = mockFailure("TEST ERROR");
const expectFailureResponseCommitted = (commit: jest.Mock, idx = 0) => {
expect(commit.mock.calls[idx]).toEqual([
"addError",
{ error: "OTHER_ERROR", detail: "TEST ERROR" }
]);
};

let mockWorkerResultType = "sketch";
class MockWorker implements Partial<Worker> {
url: string
Expand Down Expand Up @@ -66,16 +74,35 @@ describe("Actions", () => {
);
});

it("getUser fetches and commits user info", async () => {
it("getUser fetches and commits user info and dispatches get Microreact token", async () => {
mockAxios.onGet(`${serverUrl}/user`)
.reply(200, responseSuccess({ id: "12345", name: "Beebop", provider: "google" }));
const commit = jest.fn();
const dispatch = jest.fn();
const state = { microreactToken: null };
await actions.getUser({ commit, dispatch, state } as any);

expect(commit).toHaveBeenCalledWith(
"setUser",
{ id: "12345", name: "Beebop", provider: "google" }
);

expect(dispatch).toHaveBeenCalledWith("getMicroreactToken");
});

it("getUser does not dispatch get Microreact token if token is already present", async () => {
mockAxios.onGet(`${serverUrl}/user`)
.reply(200, responseSuccess({ id: "12345", name: "Beebop", provider: "google" }));
const commit = jest.fn();
await actions.getUser({ commit } as any);
const dispatch = jest.fn();
const state = { microreactToken: "abcd" };
await actions.getUser({ commit, dispatch, state } as any);

expect(commit).toHaveBeenCalledWith(
"setUser",
{ id: "12345", name: "Beebop", provider: "google" }
);
expect(dispatch).not.toHaveBeenCalled();
});

it("newProject clears state, posts project name and commits returned id", async () => {
Expand Down Expand Up @@ -417,14 +444,15 @@ describe("Actions", () => {
expect(global.URL.createObjectURL).toHaveBeenCalledTimes(1);
});

it("buildMicroreactURL makes axios call and updates results", async () => {
it("buildMicroreactURL makes axios call and updates results, and dispatches persist token", async () => {
const commit = jest.fn();
const dispatch = jest.fn();
const state = mockRootState({
projectHash: "randomHash"
});
const expResponse = responseSuccess({ cluster: 7, url: "microreact.org/mock" });
mockAxios.onPost(`${serverUrl}/microreactURL`).reply(200, expResponse);
await actions.buildMicroreactURL({ commit, state } as any, { cluster: 7, token: "some_token" });
await actions.buildMicroreactURL({ commit, state, dispatch } as any, { cluster: 7, token: "some_token" });
expect(mockAxios.history.post[0].url).toEqual(`${serverUrl}/microreactURL`);
expect(commit.mock.calls[0]).toEqual([
"setToken",
Expand All @@ -434,6 +462,25 @@ describe("Actions", () => {
"addMicroreactURL",
expResponse.data
]);
expect(dispatch).toHaveBeenCalledWith("persistMicroreactToken", "some_token");
});

it("buildMicroreacthURL does not commit or persist token if already present in state", async () => {
const commit = jest.fn();
const dispatch = jest.fn();
const state = mockRootState({
projectHash: "randomHash",
microreactToken: "some_token"
});
const expResponse = responseSuccess({ cluster: 7, url: "microreact.org/mock" });
mockAxios.onPost(`${serverUrl}/microreactURL`).reply(200, expResponse);
await actions.buildMicroreactURL({ commit, state, dispatch } as any, { cluster: 7, token: "some_token" });
expect(commit.mock.calls.length).toBe(1);
expect(commit.mock.calls[0]).toEqual([
"addMicroreactURL",
expResponse.data
]);
expect(dispatch).not.toHaveBeenCalled();
});

it("getGraphml makes axios call and updates results", async () => {
Expand Down Expand Up @@ -612,11 +659,45 @@ describe("Actions", () => {
type: ValueTypes.AMR
};
const url = `${serverUrl}/project/testProjectId/amr/1234`;
mockAxios.onPost(url).reply(500, mockFailure("TEST ERROR"));
mockAxios.onPost(url).reply(500, failureResponse);
await actions.postAMR({ commit, state } as any, amrData);
expectFailureResponseCommitted(commit);
});

it("persistMicroreactToken posts token to api", async () => {
const url = `${serverUrl}/microreactToken`;
mockAxios.onPost(url).reply(200, mockSuccess(null));
const commit = jest.fn();
await actions.persistMicroreactToken({ commit } as any, "some_token");
expect(mockAxios.history.post[0].url).toBe(url);
expect(JSON.parse(mockAxios.history.post[0].data)).toStrictEqual({ token: "some_token" });
expect(commit).not.toHaveBeenCalled();
});

it("persistMicroreactToken commits error", async () => {
const url = `${serverUrl}/microreactToken`;
mockAxios.onPost(url).reply(500, failureResponse);
const commit = jest.fn();
await actions.persistMicroreactToken({ commit } as any, "some_token");
expectFailureResponseCommitted(commit);
});

it("getMicroreactToken commits token response", async () => {
const url = `${serverUrl}/microreactToken`;
mockAxios.onGet(url).reply(200, mockSuccess("token_from_api"));
const commit = jest.fn();
await actions.getMicroreactToken({ commit } as any);
expect(commit.mock.calls[0]).toEqual([
"addError",
{ error: "OTHER_ERROR", detail: "TEST ERROR" }
"setToken",
"token_from_api"
]);
});

it("getMicroreactToken commits error", async () => {
const url = `${serverUrl}/microreactToken`;
mockAxios.onGet(url).reply(500, failureResponse);
const commit = jest.fn();
await actions.getMicroreactToken({ commit } as any);
expectFailureResponseCommitted(commit);
});
});
2 changes: 1 addition & 1 deletion app/client/tests/unit/store/getters.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getters } from "@/store/getters";
import { ProjectNameCheckResult } from "@/types";
import { mockRootState } from "../../mocks";
import {ProjectNameCheckResult} from "@/types";

describe("getters", () => {
it("calculates analysisProgress", () => {
Expand Down
2 changes: 2 additions & 0 deletions app/server/src/configureApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,6 @@ export const configureApp = ((app, config) => {
app.use(express.urlencoded({
extended: true
}));

app.locals.encryptKey = config.ENCRYPT_KEY;
})
18 changes: 17 additions & 1 deletion app/server/src/controllers/indexController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import axios from "axios";
import asyncHandler from "../errors/asyncHandler";
import {BeebopRunRequest, PoppunkRequest} from "../types/requestTypes";
import {userStore} from "../db/userStore";
import {handleAPIError} from "../utils";
import {handleAPIError, sendSuccess} from "../utils";
import encryption from "../encryption";

export default (config) => {
return {
Expand Down Expand Up @@ -101,5 +102,20 @@ export default (config) => {
handleAPIError(request, response, error);
});
},

async saveMicroreactToken(request, response) {
const {redis} = request.app.locals;
const { token } = request.body;
const encryptedToken = encryption.encrypt(token, request);
await userStore(redis).saveEncryptedMicroreactToken(request, encryptedToken);
sendSuccess(response, null);
},

async getMicroreactToken(request, response) {
const {redis} = request.app.locals;
const encryptedToken = await userStore(redis).getEncryptedMicroreactToken(request);
const token = encryptedToken ? encryption.decrypt(encryptedToken, request) : null;
sendSuccess(response, token);
}
}
}
12 changes: 12 additions & 0 deletions app/server/src/db/userStore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Redis from "ioredis";
import {uid} from "uid";
import {AMR} from "../types/models";
import {Request} from "express";

const BEEBOP_PREFIX = "beebop:";

Expand All @@ -16,6 +17,7 @@ export class UserStore {
private _projectKey = (projectId: string) => `${BEEBOP_PREFIX}project:${projectId}`;
private _projectSamplesKey = (projectId: string) => `${this._projectKey(projectId)}:samples`;
private _projectSampleKey = (projectId: string, sampleId: string) => `${this._projectKey(projectId)}:sample:${sampleId}`;
private _userKey = (userId: string) => `${BEEBOP_PREFIX}user:${userId}`;

private _newProjectId = () => uid(32);
private _sampleId = (sampleHash: string, fileName: string) => `${sampleHash}:${fileName}`;
Expand Down Expand Up @@ -94,6 +96,16 @@ export class UserStore {
async getProjectSampleCount(projectId: string) {
return this._redis.scard(this._projectSamplesKey(projectId));
}

async saveEncryptedMicroreactToken(request: Request, encryptedToken: Buffer) {
const user = this._userIdFromRequest(request);
await this._redis.hset(this._userKey(user), "microreactToken", encryptedToken);
}

async getEncryptedMicroreactToken(request: Request) {
const user = this._userIdFromRequest(request);
return this._redis.hgetBuffer(this._userKey(user), "microreactToken");
}
}

export const userStore = (redis: Redis) => new UserStore(redis);
26 changes: 26 additions & 0 deletions app/server/src/encryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { randomBytes, createCipheriv, createDecipheriv } from "crypto";
import { Request } from "express";

const algorithm = 'aes-256-ctr'
const encoding = "utf16le";
const ivLength = 16;

const initVector = () => randomBytes(ivLength);
const encryptKey = (request: Request) => request.app.locals.encryptKey;

export default {
encrypt: (text: string, request: Request): Buffer => {
const iv = initVector();
const cipher = createCipheriv(algorithm, encryptKey(request), iv);
const encrypted = cipher.update(text, encoding);
return Buffer.concat([iv, encrypted, cipher.final()])
},
decrypt: (buffer: Buffer, request: Request): string => {
const iv = buffer.slice(0, ivLength);
const encrypted = buffer.slice(ivLength);
const decipher = createDecipheriv(algorithm, encryptKey(request), iv);
let decrypted = decipher.update(encrypted, undefined, encoding);
decrypted += decipher.final(encoding);
return decrypted;
}
}
Loading
Loading