Merge pull request #284 from zkldi:zkldi/issue-278-ARC-Integration-Management

zkldi/issue-278-ARC-Integration-Management
This commit is contained in:
zkldi
2021-08-12 19:29:13 +01:00
committed by GitHub
12 changed files with 622 additions and 5 deletions
+1 -1
View File
@@ -70,7 +70,7 @@
"redis": "3.1.2",
"rimraf": "3.0.2",
"safe-json-stringify": "1.2.0",
"tachi-common": "^0.1.9",
"tachi-common": "^0.1.10",
"typescript": "4.3.4",
"winston": "3.3.3"
},
+4 -4
View File
@@ -51,7 +51,7 @@ specifiers:
rimraf: 3.0.2
safe-json-stringify: 1.2.0
supertest: 6.1.3
tachi-common: ^0.1.9
tachi-common: ^0.1.10
tap: 15.0.9
ts-node: 10.0.0
tsconfig-paths: 3.10.1
@@ -84,7 +84,7 @@ dependencies:
redis: 3.1.2
rimraf: 3.0.2
safe-json-stringify: 1.2.0
tachi-common: 0.1.9_ts-node@10.0.0+typescript@4.3.4
tachi-common: 0.1.10_ts-node@10.0.0+typescript@4.3.4
typescript: 4.3.4
winston: 3.3.3
@@ -3774,8 +3774,8 @@ packages:
strip-ansi: 6.0.0
dev: true
/tachi-common/0.1.9_ts-node@10.0.0+typescript@4.3.4:
resolution: {integrity: sha512-nNChWe41kwizagY9tS5PCHTTSbT7VP87lejXn8sX19x+StNax/KJDyGhwXEuYyqo0pCoPYttHGZc4Aar6SgVyQ==}
/tachi-common/0.1.10_ts-node@10.0.0+typescript@4.3.4:
resolution: {integrity: sha512-yiiaQqgCGt8E9HWL9k4PFWAQ6b6WEK1LIIhJlPmS0zQf/qDx8aM9gugFhBogX9ilLFC0ZVj8i3X8YozydAILNQ==}
dependencies:
monk: 7.3.4
tap: 15.0.9_ts-node@10.0.0+typescript@4.3.4
@@ -0,0 +1,180 @@
import db from "external/mongo/db";
import t from "tap";
import { CloseAllConnections } from "test-utils/close-connections";
import { CreateFakeAuthCookie } from "test-utils/fake-auth";
import mockApi from "test-utils/mock-api";
import ResetDBState from "test-utils/resets";
t.test("GET /api/v1/users/:userID/integrations/arc", async (t) => {
t.beforeEach(ResetDBState);
const cookie = await CreateFakeAuthCookie(mockApi);
t.test("Should return the current authentication state.", async (t) => {
await db["arc-saved-profiles"].insert({
userID: 1,
accountID: "foobar",
forImportType: "api/arc-iidx",
});
await db["arc-saved-profiles"].insert({
userID: 2,
accountID: "barfoo",
forImportType: "api/arc-sdvx",
});
const res = await mockApi.get("/api/v1/users/1/integrations/arc").set("Cookie", cookie);
t.hasStrict(res.body.body, {
iidx: { userID: 1, accountID: "foobar", forImportType: "api/arc-iidx" },
sdvx: null,
});
t.end();
});
t.test("Should reject APIKey authentication.", async (t) => {
await db["api-tokens"].insert({
identifier: "foobar",
permissions: {},
token: "foobar",
userID: 1,
});
const res = await mockApi
.get("/api/v1/users/1/integrations/arc")
.set("Authorization", "Bearer foobar");
t.equal(res.statusCode, 403);
t.match(res.body.description, /this request cannot be performed by an api key/iu);
t.end();
});
t.end();
});
t.test("PATCH /api/v1/users/:userID/integrations/arc", async (t) => {
t.beforeEach(ResetDBState);
const cookie = await CreateFakeAuthCookie(mockApi);
t.test("Should update a users configured accountIDs.", async (t) => {
const res = await mockApi
.patch("/api/v1/users/1/integrations/arc")
.set("Cookie", cookie)
.send({
iidx: "newAccountID",
});
t.equal(res.statusCode, 200);
t.hasStrict(res.body.body, {
iidx: {
accountID: "newAccountID",
userID: 1,
forImportType: "api/arc-iidx",
},
sdvx: null,
});
const dbRes = await db["arc-saved-profiles"].findOne({
userID: 1,
forImportType: "api/arc-iidx",
});
t.strictSame(dbRes, {
accountID: "newAccountID",
userID: 1,
forImportType: "api/arc-iidx",
});
t.end();
});
t.test("Should update accountIDs if one already exists", async (t) => {
await db["arc-saved-profiles"].insert({
userID: 1,
accountID: "OLD_ACCOUNT_ID",
forImportType: "api/arc-iidx",
});
const res = await mockApi
.patch("/api/v1/users/1/integrations/arc")
.set("Cookie", cookie)
.send({
iidx: "newAccountID",
});
t.equal(res.statusCode, 200);
t.hasStrict(res.body.body, {
iidx: {
accountID: "newAccountID",
userID: 1,
forImportType: "api/arc-iidx",
},
sdvx: null,
});
const dbRes = await db["arc-saved-profiles"].findOne({
userID: 1,
forImportType: "api/arc-iidx",
});
t.strictSame(dbRes, {
accountID: "newAccountID",
userID: 1,
forImportType: "api/arc-iidx",
});
t.end();
});
t.test("Should remove accountIDs if nulled", async (t) => {
await db["arc-saved-profiles"].insert({
userID: 1,
accountID: "OLD_ACCOUNT_ID",
forImportType: "api/arc-iidx",
});
const res = await mockApi
.patch("/api/v1/users/1/integrations/arc")
.set("Cookie", cookie)
.send({
iidx: null,
});
t.equal(res.statusCode, 200);
t.hasStrict(res.body.body, {
iidx: null,
sdvx: null,
});
const dbRes = await db["arc-saved-profiles"].findOne({
userID: 1,
forImportType: "api/arc-iidx",
});
t.equal(dbRes, null);
t.end();
});
t.test("Should reject requests with no modifications", async (t) => {
const res = await mockApi
.patch("/api/v1/users/1/integrations/arc")
.set("Cookie", cookie)
.send({});
t.equal(res.statusCode, 400);
t.match(res.body.description, "Invalid request to modify nothing.");
t.end();
});
t.end();
});
t.teardown(CloseAllConnections);
@@ -0,0 +1,128 @@
/* eslint-disable no-await-in-loop */
import { Router } from "express";
import db from "external/mongo/db";
import { SYMBOL_TachiData } from "lib/constants/tachi";
import CreateLogCtx from "lib/logger/logger";
import prValidate from "server/middleware/prudence-validate";
import { RequireKamaitachi } from "server/middleware/type-require";
import { GetArcAuth } from "utils/queries/auth";
import { FormatUserDoc } from "utils/user";
import { RequireSelfRequestFromUser } from "../../middleware";
const router: Router = Router({ mergeParams: true });
const logger = CreateLogCtx(__filename);
router.use(RequireKamaitachi);
router.use(RequireSelfRequestFromUser);
/**
* Return this users integration status for ARC.
* @name GET /api/v1/users/:userID/integrations/arc
*/
router.get("/", async (req, res) => {
const user = req[SYMBOL_TachiData]!.requestedUser!;
const [iidx, ddr, sdvx] = await Promise.all([
GetArcAuth(user.id, "api/arc-iidx"),
GetArcAuth(user.id, "api/arc-ddr"),
GetArcAuth(user.id, "api/arc-sdvx"),
]);
return res.status(200).json({
success: true,
description: `Retrieved integration information for ARC.`,
body: {
iidx,
ddr,
sdvx,
},
});
});
/**
* Modify this users integrations for ARC.
* @name PATCH /api/v1/users/:userID/integrations/arc
*/
router.patch(
"/",
prValidate({ iidx: "*?string", ddr: "*?string", sdvx: "*?string" }),
async (req, res) => {
const user = req[SYMBOL_TachiData]!.requestedUser!;
if (Object.keys(req.body).length === 0) {
return res.status(400).json({
success: false,
description: `Invalid request to modify nothing.`,
});
}
const [iidx, ddr, sdvx] = await Promise.all([
GetArcAuth(user.id, "api/arc-iidx"),
GetArcAuth(user.id, "api/arc-ddr"),
GetArcAuth(user.id, "api/arc-sdvx"),
]);
const existingData = { iidx, ddr, sdvx };
for (const key of ["iidx", "ddr", "sdvx"] as const) {
const importType = `api/arc-${key}` as const;
if (req.body[key] === null) {
logger.info(
`User ${FormatUserDoc(user)} removed ARC integration for ${importType}.`
);
await db["arc-saved-profiles"].remove(
{
userID: user.id,
forImportType: importType,
},
{
single: true,
}
);
} else if (req.body[key]) {
if (existingData[key]) {
logger.info(`User updated ARC integration for ${importType}.`);
await db["arc-saved-profiles"].update(
{
userID: user.id,
forImportType: importType,
},
{
$set: {
accountID: req.body[key],
},
}
);
} else {
logger.info(`User created ARC integration for ${importType}.`);
await db["arc-saved-profiles"].insert({
userID: user.id,
forImportType: importType,
accountID: req.body[key],
});
}
}
}
const [iidx2, ddr2, sdvx2] = await Promise.all([
GetArcAuth(user.id, "api/arc-iidx"),
GetArcAuth(user.id, "api/arc-ddr"),
GetArcAuth(user.id, "api/arc-sdvx"),
]);
return res.status(200).json({
success: true,
description: `Updated ARC integrations.`,
body: {
iidx: iidx2,
ddr: ddr2,
sdvx: sdvx2,
},
});
}
);
export default router;
@@ -0,0 +1,32 @@
import t from "tap";
import expMiddlewareMock from "express-request-mock";
import { ValidateKaiType } from "./middleware";
t.test("#ValidateKaiType", (t) => {
const k = async (k: string) => {
const { res } = await expMiddlewareMock(ValidateKaiType, { params: { kaiType: k } });
return res.statusCode;
};
t.test("Should allow flo, eag or min, case insensitively.", async (t) => {
t.equal(await k("flo"), 200);
t.equal(await k("eag"), 200);
t.equal(await k("min"), 200);
t.equal(await k("FLO"), 200);
t.equal(await k("EAG"), 200);
t.equal(await k("MIN"), 200);
t.equal(await k("FlO"), 200);
t.equal(await k("EaG"), 200);
t.equal(await k("MiN"), 200);
t.equal(await k("nonsense"), 400);
t.equal(await k("bad"), 400);
t.equal(await k(""), 400);
t.equal(await k("FLO2"), 400);
t.equal(await k("2FLO"), 400);
t.end();
});
t.end();
});
@@ -0,0 +1,12 @@
import { RequestHandler } from "express";
export const ValidateKaiType: RequestHandler = (req, res, next) => {
if (!["min", "flo", "eag"].includes(req.params.kaiType.toLowerCase())) {
return res.status(400).json({
success: false,
description: `Invalid kaiType - Expected min, flo or eag.`,
});
}
return next();
};
@@ -0,0 +1,68 @@
import db from "external/mongo/db";
import t from "tap";
import { CloseAllConnections } from "test-utils/close-connections";
import { CreateFakeAuthCookie } from "test-utils/fake-auth";
import mockApi from "test-utils/mock-api";
import ResetDBState from "test-utils/resets";
t.test("GET /api/v1/users/:userID/integrations/kai/:kaiType", async (t) => {
t.beforeEach(ResetDBState);
const cookie = await CreateFakeAuthCookie(mockApi);
t.test("Should return false if unauthed with this kaiType", async (t) => {
const res = await mockApi.get("/api/v1/users/1/integrations/kai/flo").set("Cookie", cookie);
t.equal(res.statusCode, 200);
t.equal(res.body.body.authStatus, false);
t.end();
});
t.test("Should return true if authed with this kaiType", async (t) => {
await db["kai-auth-tokens"].insert({
refreshToken: "refresh",
service: "FLO",
token: "bar",
userID: 1,
});
const res = await mockApi.get("/api/v1/users/1/integrations/kai/flo").set("Cookie", cookie);
t.equal(res.statusCode, 200);
t.equal(res.body.body.authStatus, true);
t.end();
});
t.test("Should return the auth status of this user specifically.", async (t) => {
await db["kai-auth-tokens"].insert([
{
refreshToken: "refresh",
service: "EAG",
token: "bar",
userID: 1,
},
{
refreshToken: "refresh",
service: "FLO",
token: "baz",
userID: 2,
},
]);
const res = await mockApi.get("/api/v1/users/1/integrations/kai/flo").set("Cookie", cookie);
t.equal(res.statusCode, 200);
t.equal(res.body.body.authStatus, false);
t.end();
});
t.end();
});
// test is currently undoable due to issues with mocking out real Fetch calls.
t.todo("PATCH /api/v1/userse/:userID/integrations/kai/:kaiType/oauth2callback");
t.teardown(CloseAllConnections);
@@ -0,0 +1,163 @@
import { Router } from "express";
import CreateLogCtx from "lib/logger/logger";
import {
GetKaiTypeClientCredentials,
KaiTypeToBaseURL,
} from "lib/score-import/import-types/common/api-kai/utils";
import prValidate from "server/middleware/prudence-validate";
import { ValidateKaiType } from "./middleware";
import p from "prudence";
import db from "external/mongo/db";
import { SYMBOL_TachiData } from "lib/constants/tachi";
import { FormatUserDoc } from "utils/user";
import { GetKaiAuth } from "utils/queries/auth";
import { RequireSelfRequestFromUser } from "../../../middleware";
import { RequireKamaitachi } from "server/middleware/type-require";
const router: Router = Router({ mergeParams: true });
const logger = CreateLogCtx(__filename);
router.use(RequireKamaitachi, RequireSelfRequestFromUser, ValidateKaiType);
/**
* Return the authentication status for this kaiType.
* @note - Express's types infer arg0 of "/" to mean no params, for some reason.
* the <any> generic overrides this behaviour.
*
* @name GET /api/v1/users/:userID/integrations/kai/:kaiType
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.get<any>("/", async (req, res) => {
const user = req[SYMBOL_TachiData]!.requestedUser!;
const kaiType = req.params.kaiType.toUpperCase() as "FLO" | "EAG" | "MIN";
const authDoc = await GetKaiAuth(user.id, kaiType);
return res.status(200).json({
success: true,
description: authDoc ? `User is authenticated.` : `User is unauthenticated.`,
body: {
authStatus: !!authDoc,
},
});
});
const KAI_OAUTH2_RETURN_SCHEMA = {
access_token: "string",
refresh_token: "string",
};
/**
* The OAuth2 callback used by Kai to send an intermediate token to.
* @note The way this is implemented is *really* weird due to the fact that
* the tachi-server code cannot have any knowledge of the tachi-client code,
* and the two must be agnostic.
*
* This means the tachi-client will handle the redirecting, and will check
* query params for ?code=12345 to know when to POST us with the code
* to perform an update.
*
* @param code - An intermediate code to use to get the real auth token.
*
* @name POST /api/v1/users/:userID/integrations/kai/:kaiType/oauth2callback
*/
router.post(
"/oauth2callback",
prValidate({ code: "string" }, {}, { allowExcessKeys: true }),
async (req, res) => {
const user = req[SYMBOL_TachiData]!.requestedUser!;
const kaiType = req.params.kaiType.toUpperCase() as "FLO" | "EAG" | "MIN";
const baseUrl = KaiTypeToBaseURL(kaiType);
const maybeCredentials = GetKaiTypeClientCredentials(kaiType);
if (!maybeCredentials) {
logger.severe(
`Attempted to /callback ${kaiType}, but this server has no oauth2 credentials configured for that type.`
);
return res.status(500).json({
success: false,
description: `A fatal error has occured, This has been reported.`,
});
}
const { CLIENT_SECRET, CLIENT_ID } = maybeCredentials;
const url = new URL(`${baseUrl}/oauth/token`);
url.searchParams.append("code", req.body.code);
url.searchParams.append("grant_type", "authorization_code");
url.searchParams.append("client_secret", CLIENT_SECRET);
url.searchParams.append("client_id", CLIENT_ID);
url.searchParams.append("redirect_uri", "somewhere?");
// this also isn't a POST??
const getTokenRes = await fetch(url.href);
if (getTokenRes.status !== 200) {
logger.error(
`Unexpected status of ${getTokenRes.status} from ${url.href} oauth2 flow.`
);
return res.status(500).json({
success: false,
description: `A fatal error has occured, This has been reported.`,
});
}
let json;
try {
json = await getTokenRes.json();
} catch (err) {
logger.error(`Error parsing JSON in response body from getTokenRes.`, {
res: getTokenRes,
err,
});
return res.status(500).json({
success: false,
description: `A fatal error has occured, This has been reported.`,
});
}
const err = p(json, KAI_OAUTH2_RETURN_SCHEMA, {}, { allowExcessKeys: true });
if (err) {
logger.error(`Validation error in JSON return from ${url.href}.`, { err });
return res.status(500).json({
success: false,
description: `A fatal error has occured, This has been reported.`,
});
}
await db["kai-auth-tokens"].update(
{
userID: user.id,
service: kaiType,
},
{
$set: {
userID: user.id,
service: kaiType,
refreshToken: json.refresh_token,
token: json.access_token,
},
},
{
upsert: true,
}
);
logger.info(`Updated Auth for ${kaiType} for user ${FormatUserDoc(user)}.`);
return res.status(200).json({
success: true,
description: `Successfully updated auth for ${kaiType}`,
body: {},
});
}
);
export default router;
@@ -0,0 +1,10 @@
import { Router } from "express";
import arcRouter from "./arc/router";
import kaiKaiTypeRouter from "./kai/_kaiType/router";
const router: Router = Router({ mergeParams: true });
router.use("/arc", arcRouter);
router.use("/kai/:kaiType", kaiKaiTypeRouter);
export default router;
@@ -38,6 +38,9 @@ export const GetUserFromParam: RequestHandler = async (req, res, next) => {
return next();
};
/**
* Require the user making this request to also be the user in the :userID param.
*/
export const RequireAuthedAsUser: RequestHandler = (req, res, next) => {
const user = req[SYMBOL_TachiData]!.requestedUser!;
@@ -50,3 +53,21 @@ export const RequireAuthedAsUser: RequestHandler = (req, res, next) => {
return next();
};
/**
* Require that this request is made with a Cookie, instead of any
* API key. This is for things that services should not be allowed to
* alter/access, like integration information.
*/
export const RequireSelfRequestFromUser: RequestHandler = (req, res, next) => {
const user = req[SYMBOL_TachiData]!.requestedUser!;
if (!req.session.tachi?.userID || req[SYMBOL_TachiAPIAuth].userID !== user.id) {
return res.status(403).json({
success: false,
description: `This request cannot be performed by an API key, and requires authentication.`,
});
}
return next();
};
@@ -5,6 +5,7 @@ import { GetUserFromParam } from "./middleware";
import gamePTRouter from "./games/_game/_playtype/router";
import bannerRouter from "./banner/router";
import pfpRouter from "./pfp/router";
import integrationsRouter from "./integrations/router";
const router: Router = Router({ mergeParams: true });
@@ -46,5 +47,6 @@ router.get("/game-stats", async (req, res) => {
router.use("/games/:game/:playtype", gamePTRouter);
router.use("/pfp", pfpRouter);
router.use("/banner", bannerRouter);
router.use("/integrations", integrationsRouter);
export default router;
@@ -0,0 +1 @@
[]