Prevent attempting to authenticate more than four times per hour

This only affects scripts - running this nxapi command in a terminal and the Electron app will ignore this limit.

https://github.com/samuelthomas2774/nxapi/issues/9
This commit is contained in:
Samuel Elliott 2022-07-27 15:48:19 +01:00
parent d3e5a88625
commit e12bb364a7
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
32 changed files with 109 additions and 48 deletions

View File

@ -758,7 +758,7 @@ nxapi exports it's API library and types. [See src/exports.](src/exports)
Example authenticating to the Nintendo Switch Online app:
> This is a simplified example of authenticating to the Coral API and using cached tokens. More logic is required to ensure you are using these APIs properly - [see src/common/auth/nso.ts for the authentication functions used in nxapi's CLI and Electron app](src/common/auth/nso.ts).
> This is a simplified example of authenticating to the Coral API and using cached tokens. More logic is required to ensure you are using these APIs properly - [see src/common/auth/coral.ts for the authentication functions used in nxapi's CLI and Electron app](src/common/auth/coral.ts).
```ts
import { addUserAgent } from 'nxapi';

View File

@ -4,7 +4,7 @@ import { ActiveEvent, Announcements, CurrentUser, Event, Friend, Presence, Prese
import { ErrorResponse } from './util.js';
import CoralApi from './coral.js';
import { NintendoAccountUser } from './na.js';
import { SavedToken } from '../common/auth/nso.js';
import { SavedToken } from '../common/auth/coral.js';
import { timeoutSignal } from '../util/misc.js';
import { getAdditionalUserAgents, getUserAgent } from '../util/useragent.js';

View File

@ -6,7 +6,7 @@ import { ErrorResponse } from '../../api/util.js';
import { DiscordPresence } from '../../discord/util.js';
import ipc, { events } from './ipc.js';
import { NintendoAccountUser } from '../../api/na.js';
import { SavedToken } from '../../common/auth/nso.js';
import { SavedToken } from '../../common/auth/coral.js';
import { SavedMoonToken } from '../../common/auth/moon.js';
import { BACKGROUND_COLOUR_MAIN_DARK, BACKGROUND_COLOUR_MAIN_LIGHT, DEFAULT_ACCENT_COLOUR } from './constants.js';

View File

@ -175,7 +175,8 @@ export class Store extends EventEmitter {
) {
super();
this.users = Users.coral(storage, process.env.ZNC_PROXY_URL);
// ratelimit = false, as most users.get calls are triggered by user interaction (or at startup)
this.users = Users.coral(storage, process.env.ZNC_PROXY_URL, false);
}
async saveMonitorState(monitors: PresenceMonitorManager) {

View File

@ -5,7 +5,7 @@ import { askAddNsoAccount, askAddPctlAccount } from './na-auth.js';
import { App } from './index.js';
import { WebService } from '../../api/coral-types.js';
import openWebService from './webservices.js';
import { SavedToken } from '../../common/auth/nso.js';
import { SavedToken } from '../../common/auth/coral.js';
import { SavedMoonToken } from '../../common/auth/moon.js';
import { dev, dir } from '../../util/product.js';
import { EmbeddedPresenceMonitor, EmbeddedProxyPresenceMonitor } from './monitor.js';

View File

@ -6,7 +6,7 @@ import { BrowserWindow, dialog, MessageBoxOptions, Notification, session, shell
import { getNintendoAccountSessionToken, NintendoAccountSessionToken } from '../../api/na.js';
import { ZNCA_CLIENT_ID } from '../../api/coral.js';
import { ZNMA_CLIENT_ID } from '../../api/moon.js';
import { getToken, SavedToken } from '../../common/auth/nso.js';
import { getToken, SavedToken } from '../../common/auth/coral.js';
import { getPctlToken, SavedMoonToken } from '../../common/auth/moon.js';
import { Jwt } from '../../util/jwt.js';
import { tryGetNativeImageFromUrl } from './util.js';

View File

@ -10,7 +10,7 @@ import { dev } from '../../util/product.js';
import { WebService } from '../../api/coral-types.js';
import { Store } from './index.js';
import type { NativeShareRequest, NativeShareUrlRequest } from '../preload-webservice/znca-js-api.js';
import { SavedToken } from '../../common/auth/nso.js';
import { SavedToken } from '../../common/auth/coral.js';
import { createWebServiceWindow } from './windows.js';
const debug = createDebug('app:main:webservices');

View File

@ -5,7 +5,7 @@ import createDebug from 'debug';
import type { User } from 'discord-rpc';
import type { SharingItem } from '../main/electron.js';
import type { DiscordPresenceConfiguration, DiscordPresenceSource, WindowConfiguration } from '../common/types.js';
import type { SavedToken } from '../../common/auth/nso.js';
import type { SavedToken } from '../../common/auth/coral.js';
import type { SavedMoonToken } from '../../common/auth/moon.js';
import type { UpdateCacheData } from '../../common/update.js';
import type { Announcements, CurrentUser, Friend, GetActiveEventResult, WebService, WebServices } from '../../api/coral-types.js';

View File

@ -2,7 +2,7 @@ import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nso.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken } from '../../common/auth/nso.js';
import { getToken } from '../../common/auth/coral.js';
const debug = createDebug('cli:nso:add-friend');

View File

@ -3,7 +3,7 @@ import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../nso.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken } from '../../common/auth/nso.js';
import { getToken } from '../../common/auth/coral.js';
const debug = createDebug('cli:nso:announcements');

View File

@ -3,7 +3,7 @@ import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nso.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken } from '../../common/auth/nso.js';
import { getToken } from '../../common/auth/coral.js';
import { getNintendoAccountSessionToken } from '../../api/na.js';
import { ZNCA_CLIENT_ID } from '../../api/coral.js';
import prompt from '../util/prompt.js';

View File

@ -2,7 +2,7 @@ import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nso.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken } from '../../common/auth/nso.js';
import { getToken } from '../../common/auth/coral.js';
const debug = createDebug('cli:nso:friendcode');

View File

@ -5,7 +5,7 @@ import type { Arguments as ParentArguments } from '../nso.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { hrduration } from '../../util/misc.js';
import { getToken } from '../../common/auth/nso.js';
import { getToken } from '../../common/auth/coral.js';
const debug = createDebug('cli:nso:friends');

View File

@ -9,7 +9,7 @@ import CoralApi from '../../api/coral.js';
import type { Arguments as ParentArguments } from '../nso.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken, SavedToken } from '../../common/auth/nso.js';
import { getToken, SavedToken } from '../../common/auth/coral.js';
import { NotificationManager, PresenceEvent, ZncNotifications } from '../../common/notify.js';
import { product } from '../../util/product.js';
import { parseListenAddress } from '../../util/net.js';

View File

@ -2,7 +2,7 @@ import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nso.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken } from '../../common/auth/nso.js';
import { getToken } from '../../common/auth/coral.js';
const debug = createDebug('cli:nso:lookup');

View File

@ -4,7 +4,7 @@ import persist from 'node-persist';
import type { Arguments as ParentArguments } from '../nso.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken } from '../../common/auth/nso.js';
import { getToken } from '../../common/auth/coral.js';
import { getIksmToken } from '../../common/auth/splatnet2.js';
import { EmbeddedSplatNet2Monitor, NotificationManager, ZncNotifications } from '../../common/notify.js';
import { CurrentUser, Friend, Game } from '../../api/coral-types.js';

View File

@ -3,7 +3,7 @@ import { PresencePermissions } from '../../api/coral-types.js';
import type { Arguments as ParentArguments } from '../nso.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken } from '../../common/auth/nso.js';
import { getToken } from '../../common/auth/coral.js';
const debug = createDebug('cli:nso:permissions');

View File

@ -2,7 +2,7 @@ import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nso.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken } from '../../common/auth/nso.js';
import { getToken } from '../../common/auth/coral.js';
import { DiscordPresencePlayTime } from '../../discord/util.js';
import { handleEnableSplatNet2Monitoring, TerminalNotificationManager } from './notify.js';
import { ZncDiscordPresence, ZncProxyDiscordPresence } from '../../common/presence.js';

View File

@ -2,7 +2,7 @@ import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nso.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken } from '../../common/auth/nso.js';
import { getToken } from '../../common/auth/coral.js';
import prompt from '../util/prompt.js';
const debug = createDebug('cli:nso:token');

View File

@ -2,7 +2,7 @@ import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nso.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken } from '../../common/auth/nso.js';
import { getToken } from '../../common/auth/coral.js';
const debug = createDebug('cli:nso:user');

View File

@ -3,7 +3,7 @@ import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../nso.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken } from '../../common/auth/nso.js';
import { getToken } from '../../common/auth/coral.js';
const debug = createDebug('cli:nso:webservices');

View File

@ -2,7 +2,7 @@ import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nso.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken } from '../../common/auth/nso.js';
import { getToken } from '../../common/auth/coral.js';
const debug = createDebug('cli:nso:webservicetoken');

View File

@ -2,7 +2,7 @@ import createDebug from 'debug';
import fetch from 'node-fetch';
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../nso.js';
import { getToken } from '../../common/auth/nso.js';
import { getToken } from '../../common/auth/coral.js';
import { AuthPolicy, AuthToken } from '../../api/znc-proxy.js';
import { Argv } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -3,7 +3,7 @@ import Table from './util/table.js';
import type { Arguments as ParentArguments } from '../cli.js';
import { Argv } from '../util/yargs.js';
import { initStorage } from '../util/storage.js';
import { SavedToken } from '../common/auth/nso.js';
import { SavedToken } from '../common/auth/coral.js';
import { SavedMoonToken } from '../common/auth/moon.js';
const debug = createDebug('cli:users');

View File

@ -7,7 +7,7 @@ import type { Arguments as ParentArguments } from '../util.js';
import { DiscordPresenceContext, DiscordPresencePlayTime, getDiscordPresence, getInactiveDiscordPresence } from '../../discord/util.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken } from '../../common/auth/nso.js';
import { getToken } from '../../common/auth/coral.js';
import { timeoutSignal } from '../../util/misc.js';
import { getUserAgent } from '../../util/useragent.js';

View File

@ -7,6 +7,7 @@ import { Jwt } from '../../util/jwt.js';
import { AccountLogin, CoralErrorResponse } from '../../api/coral-types.js';
import CoralApi, { ZNCA_CLIENT_ID } from '../../api/coral.js';
import ZncProxyApi from '../../api/znc-proxy.js';
import { checkUseLimit, SHOULD_LIMIT_USE } from './util.js';
const debug = createDebug('nxapi:auth:nso');
@ -24,15 +25,21 @@ export interface SavedToken {
proxy_url?: string;
}
export async function getToken(storage: persist.LocalStorage, token: string, proxy_url: string): Promise<{
export async function getToken(
storage: persist.LocalStorage, token: string, proxy_url: string, ratelimit?: boolean
): Promise<{
nso: ZncProxyApi;
data: SavedToken;
}>
export async function getToken(storage: persist.LocalStorage, token: string, proxy_url?: string): Promise<{
export async function getToken(
storage: persist.LocalStorage, token: string, proxy_url?: string, ratelimit?: boolean
): Promise<{
nso: CoralApi;
data: SavedToken;
}>
export async function getToken(storage: persist.LocalStorage, token: string, proxy_url?: string) {
export async function getToken(
storage: persist.LocalStorage, token: string, proxy_url?: string, ratelimit = SHOULD_LIMIT_USE
) {
if (!token) {
console.error('No token set. Set a Nintendo Account session token using the `--token` option or by running `nxapi nso token`.');
throw new Error('Invalid token');
@ -58,6 +65,8 @@ export async function getToken(storage: persist.LocalStorage, token: string, pro
const existingToken: SavedToken | undefined = await storage.getItem('NsoToken.' + token);
if (!existingToken || existingToken.expires_at <= Date.now()) {
if (ratelimit) await checkUseLimit(storage, 'coral', jwt.payload.sub);
console.warn('Authenticating to Nintendo Switch Online app');
debug('Authenticating to znc with session token');

View File

@ -4,6 +4,7 @@ import { ZNMA_CLIENT_ID } from '../../api/moon.js';
import { NintendoAccountSessionTokenJwtPayload, NintendoAccountToken, NintendoAccountUser } from '../../api/na.js';
import { Jwt } from '../../util/jwt.js';
import MoonApi from '../../api/moon.js';
import { checkUseLimit, SHOULD_LIMIT_USE } from './util.js';
const debug = createDebug('nxapi:auth:moon');
@ -14,7 +15,7 @@ export interface SavedMoonToken {
expires_at: number;
}
export async function getPctlToken(storage: persist.LocalStorage, token: string) {
export async function getPctlToken(storage: persist.LocalStorage, token: string, ratelimit = SHOULD_LIMIT_USE) {
if (!token) {
console.error('No token set. Set a Nintendo Account session token using the `--token` option or by running `nxapi pctl auth`.');
throw new Error('Invalid token');
@ -40,6 +41,8 @@ export async function getPctlToken(storage: persist.LocalStorage, token: string)
const existingToken: SavedMoonToken | undefined = await storage.getItem('MoonToken.' + token);
if (!existingToken || existingToken.expires_at <= Date.now()) {
if (ratelimit) await checkUseLimit(storage, 'moon', jwt.payload.sub);
console.warn('Authenticating to Nintendo Switch Parental Controls app');
debug('Authenticating to pctl with session token');

View File

@ -1,9 +1,12 @@
import createDebug from 'debug';
import persist from 'node-persist';
import { getToken } from './nso.js';
import { getToken } from './coral.js';
import NooklinkApi, { NooklinkUserApi } from '../../api/nooklink.js';
import { AuthToken, Users } from '../../api/nooklink-types.js';
import { WebServiceToken } from '../../api/coral-types.js';
import { checkUseLimit, SHOULD_LIMIT_USE } from './util.js';
import { Jwt } from '../../util/jwt.js';
import { NintendoAccountSessionTokenJwtPayload } from '../../api/na.js';
const debug = createDebug('nxapi:auth:nooklink');
@ -19,7 +22,8 @@ export interface SavedToken {
}
export async function getWebServiceToken(
storage: persist.LocalStorage, token: string, proxy_url?: string, allow_fetch_token = false
storage: persist.LocalStorage, token: string, proxy_url?: string,
allow_fetch_token = false, ratelimit = SHOULD_LIMIT_USE
) {
if (!token) {
console.error('No token set. Set a Nintendo Account session token using the `--token` option or by running `nxapi nso token`.');
@ -30,7 +34,12 @@ export async function getWebServiceToken(
if (!existingToken || existingToken.expires_at <= Date.now()) {
if (!allow_fetch_token) {
throw new Error('No valid _gtoken cookie');
throw new Error('No valid NookLink web service token');
}
if (ratelimit) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
await checkUseLimit(storage, 'nooklink', jwt.payload.sub);
}
console.warn('Authenticating to NookLink');
@ -66,7 +75,7 @@ type PromiseValue<T> = T extends PromiseLike<infer R> ? R : never;
export async function getUserToken(
storage: persist.LocalStorage, nintendoAccountToken: string, user?: string,
proxy_url?: string, allow_fetch_token = false
proxy_url?: string, allow_fetch_token = false, ratelimit = SHOULD_LIMIT_USE
) {
let wst: PromiseValue<ReturnType<typeof getWebServiceToken>> | null = null;
@ -106,6 +115,11 @@ export async function getUserToken(
if (!wst) wst = await getWebServiceToken(storage, nintendoAccountToken, proxy_url, allow_fetch_token);
const {nooklink, data: webserviceToken} = wst;
if (ratelimit) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(nintendoAccountToken);
await checkUseLimit(storage, 'nooklink-user', jwt.payload.sub);
}
console.warn('Authenticating to NookLink as user %s', user);
debug('Authenticating to NookLink as user %s', user);

View File

@ -2,9 +2,12 @@ import process from 'node:process';
import * as fs from 'node:fs';
import createDebug from 'debug';
import persist from 'node-persist';
import { getToken } from './nso.js';
import { getToken } from './coral.js';
import SplatNet2Api, { updateIksmSessionLastUsed } from '../../api/splatnet2.js';
import { WebServiceToken } from '../../api/coral-types.js';
import { checkUseLimit, SHOULD_LIMIT_USE } from './util.js';
import { Jwt } from '../../util/jwt.js';
import { NintendoAccountSessionTokenJwtPayload } from '../../api/na.js';
const debug = createDebug('nxapi:auth:splatnet2');
@ -27,7 +30,10 @@ export interface SavedIksmSessionToken {
last_used?: number;
}
export async function getIksmToken(storage: persist.LocalStorage, token: string, proxy_url?: string, allow_fetch_token = false) {
export async function getIksmToken(
storage: persist.LocalStorage, token: string, proxy_url?: string,
allow_fetch_token = false, ratelimit = SHOULD_LIMIT_USE
) {
if (!token) {
console.error('No token set. Set a Nintendo Account session token using the `--token` option or by running `nxapi nso token`.');
throw new Error('Invalid token');
@ -44,6 +50,11 @@ export async function getIksmToken(storage: persist.LocalStorage, token: string,
throw new Error('No valid iksm_session cookie');
}
if (ratelimit) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
await checkUseLimit(storage, 'splatnet2', jwt.payload.sub);
}
console.warn('Authenticating to SplatNet 2');
debug('Authenticating to SplatNet 2');

30
src/common/auth/util.ts Normal file
View File

@ -0,0 +1,30 @@
import createDebug from 'debug';
import * as persist from 'node-persist';
const debug = createDebug('nxapi:auth:util');
// If the parent process is a terminal, then the user is attempting to run the command manually,
// so we shouldn't restrict how many attempts they can use. If not, the command is being run by
// a script/some other program, which should be limited in case it continues to run the command
// if it fails. The Electron app overrides this as the parent process (probably) won't be a
// terminal, but most attempts to call getToken won't be automated.
export const SHOULD_LIMIT_USE = !process.stdout.isTTY;
const LIMIT_REQUESTS = 4;
const LIMIT_PERIOD = 60 * 60 * 1000; // 60 minutes
type RateLimitAttempts = number[];
export async function checkUseLimit(
storage: persist.LocalStorage,
key: string, user: string
) {
let attempts: RateLimitAttempts = await storage.getItem('RateLimitAttempts-' + key + '.' + user) ?? [];
attempts = attempts.filter(a => a >= Date.now() - LIMIT_PERIOD);
if (attempts.length >= LIMIT_REQUESTS) {
throw new Error('Too many attempts to authenticate');
}
attempts.unshift(Date.now());
await storage.setItem('RateLimitAttempts-' + key + '.' + user, attempts);
}

View File

@ -4,7 +4,7 @@ import CoralApi from '../api/coral.js';
import { ActiveEvent, Announcements, CurrentUser, Friend, Game, Presence, PresenceState, WebServices, CoralErrorResponse } from '../api/coral-types.js';
import ZncProxyApi from '../api/znc-proxy.js';
import { ErrorResponse } from '../api/util.js';
import { SavedToken } from './auth/nso.js';
import { SavedToken } from './auth/coral.js';
import { SplatNet2RecordsMonitor } from './splatnet2/monitor.js';
import Loop, { LoopResult } from '../util/loop.js';
import { getTitleIdFromEcUrl, hrduration } from '../util/misc.js';

View File

@ -3,9 +3,7 @@ import * as persist from 'node-persist';
import CoralApi from '../api/coral.js';
import ZncProxyApi from '../api/znc-proxy.js';
import { Announcements, Friends, GetActiveEventResult, WebServices, CoralSuccessResponse } from '../api/coral-types.js';
import { getToken, SavedToken } from './auth/nso.js';
import { Jwt } from '../util/jwt.js';
import { NintendoAccountSessionTokenJwtPayload } from '../api/na.js';
import { getToken, SavedToken } from './auth/coral.js';
const debug = createDebug('nxapi:users');
@ -24,11 +22,6 @@ export default class Users<T extends UserData> {
}
async get(token: string): Promise<T> {
if (debug.enabled) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
debug('Getting user for token', jwt.payload.sub);
}
const existing = this.users.get(token);
if (existing && existing.expires_at >= Date.now()) {
@ -47,11 +40,11 @@ export default class Users<T extends UserData> {
return promise;
}
static coral(storage: persist.LocalStorage, znc_proxy_url: string): Users<CoralUser<ZncProxyApi>>
static coral(storage: persist.LocalStorage, znc_proxy_url?: string): Users<CoralUser>
static coral(storage: persist.LocalStorage, znc_proxy_url?: string) {
static coral(storage: persist.LocalStorage, znc_proxy_url: string, ratelimit?: boolean): Users<CoralUser<ZncProxyApi>>
static coral(storage: persist.LocalStorage, znc_proxy_url?: string, ratelimit?: boolean): Users<CoralUser>
static coral(storage: persist.LocalStorage, znc_proxy_url?: string, ratelimit?: boolean) {
return new Users(async token => {
const {nso, data} = await getToken(storage, token, znc_proxy_url);
const {nso, data} = await getToken(storage, token, znc_proxy_url, ratelimit);
const [announcements, friends, webservices, active_event] = await Promise.all([
nso.getAnnouncements(),