diff --git a/api/global.d.ts b/api/global.d.ts index a245587..c2f25b5 100644 --- a/api/global.d.ts +++ b/api/global.d.ts @@ -1,13 +1,10 @@ +import { JwtPayload } from 'jsonwebtoken'; import { ApiApp } from './shapes/app'; import { UserInfo } from './shapes/user'; declare module 'express-serve-static-core' { interface Request { - jwt?: { - sub: string; - iss: string; - exp: number; - }; + jwt?: JwtPayload; user?: UserInfo; diff --git a/api/routes/auth-token.ts b/api/routes/auth-token.ts index 5f4e41a..a5a3bf0 100644 --- a/api/routes/auth-token.ts +++ b/api/routes/auth-token.ts @@ -1,22 +1,18 @@ -import { AuthTokenRequest, AuthTokenResponse } from '../shapes/auth'; +import * as Sentry from '@sentry/node'; import { ServerResponse, UserMembershipData } from 'bungie-api-ts/user'; -import superagent from 'superagent'; import asyncHandler from 'express-async-handler'; +import superagent from 'superagent'; import util from 'util'; -import * as Sentry from '@sentry/node'; +import { AuthTokenRequest, AuthTokenResponse } from '../shapes/auth'; -import { sign, Secret, SignOptions } from 'jsonwebtoken'; -import { badRequest } from '../utils'; +import { Secret, sign, SignOptions } from 'jsonwebtoken'; +import _ from 'lodash'; import { metrics } from '../metrics'; +import { badRequest } from '../utils'; const TOKEN_EXPIRES_IN = 30 * 24 * 60 * 60; // 30 days -const signJwt = util.promisify< - string | Buffer | object, - Secret, - SignOptions, - string ->(sign); +const signJwt = util.promisify(sign); export const authTokenHandler = asyncHandler(async (req, res) => { const { bungieAccessToken, membershipId } = req.body as AuthTokenRequest; @@ -42,13 +38,36 @@ export const authTokenHandler = asyncHandler(async (req, res) => { const responseData = bungieResponse.body as ServerResponse; const serverMembershipId = responseData.Response.bungieNetUser.membershipId; + const primaryMembershipId = responseData.Response.primaryMembershipId; + const profileIds = _.sortBy( + responseData.Response.destinyMemberships + .filter((m) => !m.crossSaveOverride) + .map((m) => m.membershipId), + // Sort the primary membership ID so it's always the first one (if it + // exists?). The only reason someone would have multiple accounts is if + // they don't have cross-save enabled. + (membershipId) => (membershipId === primaryMembershipId ? 0 : 1) + ); if (serverMembershipId === membershipId) { // generate and return a token - const token = await signJwt({}, process.env.JWT_SECRET!, { - subject: membershipId, - issuer: apiApp.dimApiKey, - expiresIn: TOKEN_EXPIRES_IN, - }); + const token = await signJwt( + { + // Save the IDs of all the profiles this account can see, to allow us + // to control access at the Destiny Profile level instead of the + // Bungie.net account level. This is because Destiny profiles can + // apparently be reassigned to different Destiny IDs all the time. We + // stuff this in the JWT so we don't have to check with Bungie.net for + // every action. + profileIds, + }, + process.env.JWT_SECRET!, + { + subject: membershipId, + issuer: apiApp.dimApiKey, + expiresIn: TOKEN_EXPIRES_IN, + // TODO: save all profile memberships + } + ); const response: AuthTokenResponse = { accessToken: token, diff --git a/api/routes/profile.ts b/api/routes/profile.ts index 0a5fa56..d9ee193 100644 --- a/api/routes/profile.ts +++ b/api/routes/profile.ts @@ -11,7 +11,7 @@ import { metrics } from '../metrics'; import { DestinyVersion } from '../shapes/general'; import { ProfileResponse } from '../shapes/profile'; import { defaultSettings } from '../shapes/settings'; -import { badRequest, isValidPlatformMembershipId } from '../utils'; +import { badRequest, checkPlatformMembershipId, isValidPlatformMembershipId } from '../utils'; const validComponents = new Set([ 'settings', @@ -23,17 +23,19 @@ const validComponents = new Set([ ]); export const profileHandler = asyncHandler(async (req, res) => { - const { bungieMembershipId } = req.user!; + const { bungieMembershipId, profileIds } = req.user!; const { id: appId } = req.dimApp!; metrics.increment('profile.app.' + appId, 1); - const platformMembershipId = (req.query.platformMembershipId as string) || undefined; + const platformMembershipId = req.query.platformMembershipId?.toString(); if (platformMembershipId && !isValidPlatformMembershipId(platformMembershipId)) { badRequest(res, `platformMembershipId ${platformMembershipId} is not in the right format`); return; } + checkPlatformMembershipId(platformMembershipId, profileIds, 'profile'); + const destinyVersion: DestinyVersion = req.query.destinyVersion ? (parseInt(req.query.destinyVersion.toString(), 10) as DestinyVersion) : 2; @@ -43,7 +45,7 @@ export const profileHandler = asyncHandler(async (req, res) => { return; } - const components = ((req.query.components as string) || '').split(/\s*,\s*/); + const components = (req.query.components?.toString() || '').split(/\s*,\s*/); if (components.some((c) => !validComponents.has(c))) { badRequest( @@ -63,6 +65,7 @@ export const profileHandler = asyncHandler(async (req, res) => { const response: ProfileResponse = {}; if (components.includes('settings')) { + // TODO: should settings be stored under profile too?? maybe primary profile ID? const start = new Date(); const storedSettings = await getSettings(client, bungieMembershipId); diff --git a/api/routes/update.ts b/api/routes/update.ts index 522d99e..6cf3247 100644 --- a/api/routes/update.ts +++ b/api/routes/update.ts @@ -30,7 +30,12 @@ import { UsedSearchUpdate, } from '../shapes/profile'; import { Settings } from '../shapes/settings'; -import { badRequest, isValidItemId, isValidPlatformMembershipId } from '../utils'; +import { + badRequest, + checkPlatformMembershipId, + isValidItemId, + isValidPlatformMembershipId, +} from '../utils'; /** * Update profile information. This accepts a list of update operations and @@ -39,7 +44,7 @@ import { badRequest, isValidItemId, isValidPlatformMembershipId } from '../utils * Note that you can't mix updates for multiple profiles - you'll have to make multiple requests. */ export const updateHandler = asyncHandler(async (req, res) => { - const { bungieMembershipId } = req.user!; + const { bungieMembershipId, profileIds } = req.user!; const { id: appId } = req.dimApp!; metrics.increment('update.app.' + appId, 1); const request = req.body as ProfileUpdateRequest; @@ -51,6 +56,8 @@ export const updateHandler = asyncHandler(async (req, res) => { return; } + checkPlatformMembershipId(platformMembershipId, profileIds, 'update'); + if (destinyVersion !== 1 && destinyVersion !== 2) { badRequest(res, `destinyVersion ${destinyVersion} is not in the right format`); return; diff --git a/api/server.ts b/api/server.ts index 069e151..4e96536 100644 --- a/api/server.ts +++ b/api/server.ts @@ -101,8 +101,9 @@ app.use((req, _, next) => { next(new Error('Expected JWT info')); } else { req.user = { - bungieMembershipId: parseInt(req.jwt.sub, 10), - dimApiKey: req.jwt.iss, + bungieMembershipId: parseInt(req.jwt.sub!, 10), + dimApiKey: req.jwt.iss!, + profileIds: req.jwt['profileIds'] ?? [], }; next(); } diff --git a/api/shapes/user.ts b/api/shapes/user.ts index ad9b929..6978c67 100644 --- a/api/shapes/user.ts +++ b/api/shapes/user.ts @@ -3,4 +3,6 @@ export interface UserInfo { bungieMembershipId: number; /** The DIM App API key this token was issued for */ dimApiKey: string; + /** A list of Destiny 2 membership profiles this account can access. */ + profileIds: string[]; } diff --git a/api/utils.ts b/api/utils.ts index da33866..f8cb21e 100644 --- a/api/utils.ts +++ b/api/utils.ts @@ -1,5 +1,6 @@ import { Response } from 'express'; import _ from 'lodash'; +import { metrics } from './metrics'; export function camelize(data: object) { return _.mapKeys(data, (_value, key) => _.camelCase(key)) as T; @@ -29,3 +30,27 @@ export function isValidPlatformMembershipId(platformMembershipId: string) { function isNumberSequence(str: string) { return /^\d{1,32}$/.test(str); } + +/** + * Check whether the platform membership ID provided is in the JWT's list of profile IDs. + */ +export function checkPlatformMembershipId( + platformMembershipId: string | undefined, + profileIds: string[], + metricsPrefix: string +) { + // For now, don't enforce that the JWT includes profile IDs, but track whether they would + if (platformMembershipId) { + if (profileIds.length) { + metrics.increment( + metricsPrefix + + '.profileIds.' + + (profileIds.includes(platformMembershipId) ? 'match' : 'noMatch') + + '.count', + 1 + ); + } else { + metrics.increment(metricsPrefix + '.profileIds.missing.count', 1); + } + } +}