From 1a3694772058574dbf66f8f51122a4b34d6533c1 Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Tue, 22 Mar 2022 23:39:56 +0000 Subject: [PATCH] Add API proxy access tokens This allows providing access to the API proxy server as your account but only to access certain data (probably user/friend presence for Discord). For example a token generated using `nxapi nso znc-proxy-tokens --znc-proxy-url "http://[::1]:12345/api/znc" create --policy-user-presence` will only be able to access your presence at http://[::1]:12345/api/znc/user/presence (with the access token provided in the Authorization header as a Bearer token or in the token query string parameter). --- src/cli/nso/http-server.ts | 227 +++++++++++++++++++++++++++++--- src/cli/nso/index.ts | 1 + src/cli/nso/znc-proxy-tokens.ts | 169 ++++++++++++++++++++++++ 3 files changed, 381 insertions(+), 16 deletions(-) create mode 100644 src/cli/nso/znc-proxy-tokens.ts diff --git a/src/cli/nso/http-server.ts b/src/cli/nso/http-server.ts index 1593b64..306e7c7 100644 --- a/src/cli/nso/http-server.ts +++ b/src/cli/nso/http-server.ts @@ -1,7 +1,9 @@ import createDebug from 'debug'; -import express from 'express'; +import express, { Request, Response } from 'express'; +import bodyParser from 'body-parser'; import * as net from 'net'; import persist from 'node-persist'; +import { v4 as uuidgen } from 'uuid'; import { ActiveEvent, Announcement, CurrentUser, Friend, Presence, WebService } from '../../api/znc-types.js'; import ZncApi from '../../api/znc.js'; import type { Arguments as ParentArguments } from '../nso.js'; @@ -13,10 +15,34 @@ declare global { interface Request { znc?: ZncApi; zncAuth?: SavedToken; + + zncAuthPolicy?: AuthPolicy; + zncAuthPolicyUser?: string; + zncAuthPolicyToken?: string; } } } +export interface AuthToken { + user: string; + policy?: AuthPolicy; + created_at: number; +} +export interface AuthPolicy { + announcements?: boolean; + list_friends?: boolean; + list_friends_presence?: boolean; + friend?: boolean; + friend_presence?: boolean; + webservices?: boolean; + activeevent?: boolean; + current_user?: boolean; + current_user_presence?: boolean; + + friends?: string[]; + friends_presence?: string[]; +} + const debug = createDebug('cli:nso:http-server'); export const command = 'http-server'; @@ -68,11 +94,47 @@ export async function handler(argv: ArgumentsCamelCase) { next(); }; + const authToken: express.RequestHandler = async (req, res, next) => { + if (req.headers['authorization']?.startsWith('Bearer ')) { + const token = req.headers['authorization'].substr(7); + + const auth: AuthToken | undefined = await storage.getItem('ZncProxyAuthPolicy.' + token); + if (!auth) return next(); + + req.zncAuthPolicy = auth.policy; + req.zncAuthPolicyUser = auth.user; + req.zncAuthPolicyToken = token; + } else if (req.query.token) { + const auth: AuthToken | undefined = await storage.getItem('ZncProxyAuthPolicy.' + req.query.token); + if (!auth) return next(); + + req.zncAuthPolicy = auth.policy; + req.zncAuthPolicyUser = auth.user; + req.zncAuthPolicyToken = '' + req.query.token; + } + + next(); + }; + function tokenUnauthorised(req: Request, res: Response) { + res.statusCode = 403; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + error: 'token_unauthorised', + })); + } + const nsoAuth: express.RequestHandler = async (req, res, next) => { try { - const auth = req.headers['authorization']; - if (!auth || !auth.startsWith('na ')) throw new Error('Requires Nintendo Account authentication'); - const nintendoAccountSessionToken = auth.substr(3); + let nintendoAccountSessionToken: string; + if (req.zncAuthPolicyUser) { + const na_token = await storage.getItem('NintendoAccountToken.' + req.zncAuthPolicyUser); + if (!na_token) throw new Error('Nintendo Account for this token must reauthenticate'); + nintendoAccountSessionToken = na_token; + } else { + const auth = req.headers['authorization']; + if (!auth || !auth.startsWith('na ')) throw new Error('Requires Nintendo Account authentication'); + nintendoAccountSessionToken = auth.substr(3); + } const {nso, data} = await getToken(storage, nintendoAccountSessionToken, argv.zncProxyUrl); @@ -99,13 +161,82 @@ export async function handler(argv: ArgumentsCamelCase) { res.end(JSON.stringify(req.zncAuth)); }); + app.get('/api/znc/token', authToken, (req, res) => { + if (!req.zncAuthPolicyToken) { + res.statusCode = 403; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({error: 'no_policy'})); + return; + } + + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(req.zncAuthPolicy)); + }); + app.delete('/api/znc/token', authToken, async (req, res) => { + if (!req.zncAuthPolicyToken) { + res.statusCode = 403; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({error: 'no_policy'})); + return; + } + + await storage.removeItem('ZncProxyAuthPolicy.' + req.zncAuthPolicyToken!); + + const tokens = new Set(await storage.getItem('ZncProxyAuthPolicies.' + req.zncAuthPolicyUser) ?? []); + tokens.delete(req.zncAuthPolicyToken); + await storage.setItem('ZncProxyAuthPolicies.' + req.zncAuthPolicyUser, [...tokens]); + + res.statusCode = 204; + res.end(); + }); + app.get('/api/znc/tokens', nsoAuth, async (req, res) => { + const token_ids: string[] | undefined = await storage.getItem('ZncProxyAuthPolicies.' + req.zncAuth!.user.id); + const tokens = (await Promise.all(token_ids?.map(async id => { + const auth: AuthToken | undefined = await storage.getItem('ZncProxyAuthPolicy.' + id); + if (!auth) return; + return { + token: id, + user: auth.user, + policy: auth.policy, + created_at: auth.created_at, + }; + }) ?? [])).filter(p => p); + + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({tokens})); + }); + app.post('/api/znc/tokens', nsoAuth, bodyParser.json(), async (req, res) => { + const token = uuidgen(); + const auth: AuthToken = { + user: req.zncAuth!.user.id, + policy: req.body.policy, + created_at: Math.floor(Date.now() / 1000), + }; + + await storage.setItem('ZncProxyAuthPolicy.' + token, auth); + + const tokens = new Set(await storage.getItem('ZncProxyAuthPolicies.' + req.zncAuth!.user.id) ?? []); + tokens.add(token); + await storage.setItem('ZncProxyAuthPolicies.' + req.zncAuth!.user.id, [...tokens]); + + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + token, + ...auth, + })); + }); + // // Announcements // This is cached for all users. // let cached_announcements: Announcement[] | null = null; - app.get('/api/znc/announcements', localAuth, nsoAuth, async (req, res) => { + app.get('/api/znc/announcements', authToken, (req, res, next) => { + if (!req.zncAuthPolicy) return next(); + if (!req.zncAuthPolicy.announcements) return tokenUnauthorised(req, res); + next(); + }, localAuth, nsoAuth, async (req, res) => { if (cached_announcements) { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ @@ -167,18 +298,36 @@ export async function handler(argv: ArgumentsCamelCase) { } }; - app.get('/api/znc/friends', localAuth, nsoAuth, getAppData, async (req, res) => { + app.get('/api/znc/friends', authToken, (req, res, next) => { + if (!req.zncAuthPolicy) return next(); + if (!req.zncAuthPolicy.list_friends) return tokenUnauthorised(req, res); + next(); + }, localAuth, nsoAuth, getAppData, async (req, res) => { const [friends, webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!; res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({friends, updated})); + res.end(JSON.stringify({ + friends: req.zncAuthPolicy?.friends ? + friends.filter(f => req.zncAuthPolicy!.friends!.includes(f.nsaId)) : friends, + updated, + })); }); - app.get('/api/znc/friends/presence', localAuth, nsoAuth, getAppData, async (req, res) => { + app.get('/api/znc/friends/presence', authToken, (req, res, next) => { + if (!req.zncAuthPolicy) return next(); + if (!req.zncAuthPolicy.list_friends_presence) return tokenUnauthorised(req, res); + next(); + }, localAuth, nsoAuth, getAppData, async (req, res) => { const [friends, webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!; const presence: Record = {}; for (const friend of friends) { + if (req.zncAuthPolicy) { + const p = req.zncAuthPolicy; + if (p.friends_presence && !p.friends_presence.includes(friend.nsaId)) continue; + if (p.friends && !p.friends_presence && !p.friends.includes(friend.nsaId)) continue; + } + presence[friend.nsaId] = friend.presence; } @@ -186,9 +335,14 @@ export async function handler(argv: ArgumentsCamelCase) { res.end(JSON.stringify(presence)); }); - app.get('/api/znc/friend/:nsid', localAuth, nsoAuth, getAppData, async (req, res) => { + app.get('/api/znc/friend/:nsaid', authToken, (req, res, next) => { + if (!req.zncAuthPolicy) return next(); + if (!req.zncAuthPolicy.friend) return tokenUnauthorised(req, res); + if (req.zncAuthPolicy.friends && !req.zncAuthPolicy.friends.includes(req.params.nsaid)) return tokenUnauthorised(req, res); + next(); + }, localAuth, nsoAuth, getAppData, async (req, res) => { const [friends, webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!; - const friend = friends.find(f => f.nsaId === req.params.nsid); + const friend = friends.find(f => f.nsaId === req.params.nsaid); if (!friend) { res.statusCode = 404; @@ -204,9 +358,15 @@ export async function handler(argv: ArgumentsCamelCase) { res.end(JSON.stringify({friend, updated})); }); - app.get('/api/znc/friend/:nsid/presence', localAuth, nsoAuth, getAppData, async (req, res) => { + app.get('/api/znc/friend/:nsaid/presence', authToken, (req, res, next) => { + if (!req.zncAuthPolicy) return next(); + if (!req.zncAuthPolicy.friend_presence) return tokenUnauthorised(req, res); + if (req.zncAuthPolicy.friends_presence && !req.zncAuthPolicy.friends_presence.includes(req.params.nsaid)) return tokenUnauthorised(req, res); + if (req.zncAuthPolicy.friends && !req.zncAuthPolicy.friends_presence && !req.zncAuthPolicy.friends.includes(req.params.nsaid)) return tokenUnauthorised(req, res); + next(); + }, localAuth, nsoAuth, getAppData, async (req, res) => { const [friends, webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!; - const friend = friends.find(f => f.nsaId === req.params.nsid); + const friend = friends.find(f => f.nsaId === req.params.nsaid); if (!friend) { res.statusCode = 404; @@ -222,7 +382,11 @@ export async function handler(argv: ArgumentsCamelCase) { res.end(JSON.stringify(friend.presence)); }); - app.get('/api/znc/webservices', localAuth, nsoAuth, getAppData, async (req, res) => { + app.get('/api/znc/webservices', authToken, (req, res, next) => { + if (!req.zncAuthPolicy) return next(); + if (!req.zncAuthPolicy.webservices) return tokenUnauthorised(req, res); + next(); + }, localAuth, nsoAuth, getAppData, async (req, res) => { const [friends, webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!; res.setHeader('Content-Type', 'application/json'); @@ -247,7 +411,11 @@ export async function handler(argv: ArgumentsCamelCase) { } }); - app.get('/api/znc/activeevent', localAuth, nsoAuth, getAppData, async (req, res) => { + app.get('/api/znc/activeevent', authToken, (req, res, next) => { + if (!req.zncAuthPolicy) return next(); + if (!req.zncAuthPolicy.activeevent) return tokenUnauthorised(req, res); + next(); + }, localAuth, nsoAuth, getAppData, async (req, res) => { const [friends, webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!; res.setHeader('Content-Type', 'application/json'); @@ -284,14 +452,23 @@ export async function handler(argv: ArgumentsCamelCase) { } }; - app.get('/api/znc/user', localAuth, nsoAuth, getUserData, async (req, res) => { + app.get('/api/znc/user', authToken, (req, res, next) => { + if (!req.zncAuthPolicy) return next(); + if (!req.zncAuthPolicy.current_user) return tokenUnauthorised(req, res); + next(); + }, localAuth, nsoAuth, getUserData, async (req, res) => { const [user, updated] = cached_userdata.get(req.zncAuth!.user.id)!; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({user, updated})); }); - app.get('/api/znc/user/presence', localAuth, nsoAuth, getUserData, async (req, res) => { + app.get('/api/znc/user/presence', authToken, (req, res, next) => { + if (!req.zncAuthPolicy) return next(); + if (!req.zncAuthPolicy.current_user_presence) return tokenUnauthorised(req, res); + if (!('current_user_presence' in req.zncAuthPolicy) && !req.zncAuthPolicy.current_user) return tokenUnauthorised(req, res); + next(); + }, localAuth, nsoAuth, getUserData, async (req, res) => { const [user, updated] = cached_userdata.get(req.zncAuth!.user.id)!; res.setHeader('Content-Type', 'application/json'); @@ -359,6 +536,24 @@ class ZncPresenceEventStream extends ZncNotifications { userNotifications: user, 'friend-notifications': friend, friendNotifications: friend, + 'splatnet2-monitor-directory': undefined, + splatnet2MonitorDirectory: undefined, + 'splatnet2-monitor-profile-image': false, + splatnet2MonitorProfileImage: false, + 'splatnet2-monitor-favourite-stage': undefined, + splatnet2MonitorFavouriteStage: undefined, + 'splatnet2-monitor-favourite-colour': undefined, + splatnet2MonitorFavouriteColour: undefined, + 'splatnet2-monitor-battles': false, + splatnet2MonitorBattles: false, + 'splatnet2-monitor-battle-summary-image': false, + splatnet2MonitorBattleSummaryImage: false, + 'splatnet2-monitor-battle-images': false, + splatnet2MonitorBattleImages: false, + 'splatnet2-monitor-coop': false, + splatnet2MonitorCoop: false, + 'splatnet2-monitor-update-interval': 180, + splatnet2MonitorUpdateInterval: 180, }, storage, token, nso, data); } diff --git a/src/cli/nso/index.ts b/src/cli/nso/index.ts index 0efd73f..f2d858b 100644 --- a/src/cli/nso/index.ts +++ b/src/cli/nso/index.ts @@ -8,3 +8,4 @@ export * as friends from './friends.js'; export * as presence from './presence.js'; export * as notify from './notify.js'; export * as httpServer from './http-server.js'; +export * as zncProxyTokens from './znc-proxy-tokens.js'; diff --git a/src/cli/nso/znc-proxy-tokens.ts b/src/cli/nso/znc-proxy-tokens.ts new file mode 100644 index 0000000..1680333 --- /dev/null +++ b/src/cli/nso/znc-proxy-tokens.ts @@ -0,0 +1,169 @@ +import createDebug from 'debug'; +import fetch from 'node-fetch'; +// @ts-expect-error +import Table from 'cli-table/lib/index.js'; +import type { Arguments as ParentArguments } from '../nso.js'; +import { Argv, getToken, initStorage } from '../../util.js'; +import { AuthPolicy, AuthToken } from './http-server.js'; + +const debug = createDebug('cli:nso:znc-proxy-tokens'); + +export const command = 'znc-proxy-tokens '; +export const desc = 'Manage access tokens for `nxapi nso http-server`'; + +interface AuthTokens { + tokens: ({ + token: string; + } & AuthToken)[]; +} + +export function builder(yargs: Argv) { + return yargs.option('user', { + describe: 'Nintendo Account ID', + type: 'string', + }).option('token', { + describe: 'Nintendo Account session token', + type: 'string', + }).command('list', 'Lists access tokens', () => {}, async argv => { + if (!argv.zncProxyUrl) { + throw new Error('Requires --znc-proxy-url'); + } + + const storage = await initStorage(argv.dataPath); + + const usernsid = argv.user ?? await storage.getItem('SelectedUser'); + const token: string = argv.token || + await storage.getItem('NintendoAccountToken.' + usernsid); + const {nso, data} = await getToken(storage, token, argv.zncProxyUrl); + + const tokens = await nso.fetch('/tokens'); + + const table = new Table({ + head: [ + 'Token', + 'Created at', + 'Access policy', + ], + }); + + for (const auth of tokens.tokens) { + table.push([ + auth.token, + new Date(auth.created_at * 1000).toISOString(), + JSON.stringify(auth.policy, null, 2) ?? 'No policy', + ]); + } + + if (!table.length) { + console.warn('No access tokens'); + return; + } + + console.log(table.toString()); + }).command('create', 'Creates an access token', yargs => { + return yargs.option('policy', { + describe: 'Restrict allowed actions', + type: 'boolean', + default: true, + }).option('policy-announcements', { + describe: 'Allow access to /announcements', + type: 'boolean', + default: false, + }).option('policy-list-friends', { + describe: 'Allow access to /friends', + type: 'boolean', + default: false, + }).option('policy-list-friends-presence', { + describe: 'Allow access to /friends/presence', + type: 'boolean', + default: false, + }).option('policy-friend', { + describe: 'Allow access to /friend/:id', + type: 'boolean', + default: false, + }).option('policy-friend-presence', { + describe: 'Allow access to /friend/:id/presence', + type: 'boolean', + default: false, + }).option('policy-webservices', { + describe: 'Allow access to /webservices', + type: 'boolean', + default: false, + }).option('policy-activeevent', { + describe: 'Allow access to /activeevent', + type: 'boolean', + default: false, + }).option('policy-user', { + describe: 'Allow access to /user', + type: 'boolean', + default: false, + }).option('policy-user-presence', { + describe: 'Allow access to /user/presence', + type: 'boolean', + default: false, + }).option('policy-friends', { + describe: 'Restrict friends to listed Nintendo Switch account IDs', + type: 'array', + }).option('policy-friends-presence', { + describe: 'Restrict friends to listed Nintendo Switch account IDs for presence only', + type: 'array', + }); + }, async argv => { + if (!argv.zncProxyUrl) { + throw new Error('Requires --znc-proxy-url'); + } + + const storage = await initStorage(argv.dataPath); + + const usernsid = argv.user ?? await storage.getItem('SelectedUser'); + const token: string = argv.token || + await storage.getItem('NintendoAccountToken.' + usernsid); + const {nso, data} = await getToken(storage, token, argv.zncProxyUrl); + + const policy: AuthPolicy | null = argv.policy ? { + announcements: argv.policyAnnouncements, + list_friends: argv.policyListFriends, + list_friends_presence: argv.policyListFriendsPresence, + friend: argv.policyFriend, + friend_presence: argv.policyFriendPresence, + webservices: argv.policyWebservices, + activeevent: argv.policyActiveevent, + current_user: argv.policyUser, + current_user_presence: argv.policyUserPresence, + + friends: argv.policyFriends as string[] | undefined, + friends_presence: argv.policyFriendsPresence as string[] | undefined, + } : null; + + const auth = await nso.fetch<{token: string;} & AuthToken>('/tokens', 'POST', JSON.stringify({policy}), { + 'Content-Type': 'application/json', + }); + + console.warn('Created access token', auth); + console.log(auth.token); + }).command('revoke ', 'Deletes an access tokens', yargs => { + return yargs.positional('token', { + describe: 'Access token to delete', + type: 'string', + demandOption: true, + }); + }, async argv => { + if (!argv.zncProxyUrl) { + throw new Error('Requires --znc-proxy-url'); + } + + const response = await fetch(argv.zncProxyUrl + '/token', { + method: 'DELETE', + headers: { + 'Authorization': 'Bearer ' + argv.token, + }, + }); + debug('fetch %s %s, response %d', 'DELETE', '/token', response.status); + + if (response.status !== 204) { + throw new Error('Unknown error ' + response.status); + } + + console.warn('Deleted access token'); + }); +}