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

Start storing profile IDs in JWT #142

Merged
merged 1 commit into from
Mar 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions api/global.d.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
51 changes: 35 additions & 16 deletions api/routes/auth-token.ts
Original file line number Diff line number Diff line change
@@ -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<string | Buffer | object, Secret, SignOptions, string>(sign);

export const authTokenHandler = asyncHandler(async (req, res) => {
const { bungieAccessToken, membershipId } = req.body as AuthTokenRequest;
Expand All @@ -42,13 +38,36 @@ export const authTokenHandler = asyncHandler(async (req, res) => {
const responseData = bungieResponse.body as ServerResponse<UserMembershipData>;

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,
Expand Down
11 changes: 7 additions & 4 deletions api/routes/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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;
Expand All @@ -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(
Expand All @@ -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);

Expand Down
11 changes: 9 additions & 2 deletions api/routes/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
2 changes: 2 additions & 0 deletions api/shapes/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
25 changes: 25 additions & 0 deletions api/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Response } from 'express';
import _ from 'lodash';
import { metrics } from './metrics';

export function camelize<T extends object>(data: object) {
return _.mapKeys(data, (_value, key) => _.camelCase(key)) as T;
Expand Down Expand Up @@ -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);
}
}
}