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

Redo logging in #1705

Merged
merged 2 commits into from
Mar 30, 2024
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
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@
"retry": "^0.13.1",
"semver": "^7.5.4",
"socket.io-client": "^4.7.2",
"steam-session": "^1.7.1",
"steam-totp": "^2.1.2",
"steam-user": "^5.0.4",
"steamid": "^2.0.0",
Expand Down
187 changes: 65 additions & 122 deletions src/classes/Bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,6 @@ import fs from 'fs';
import path from 'path';
import * as files from '../lib/files';

// Reference: https://github.com/tf2-automatic/tf2-automatic/commit/cf7b807cae11eb172a78ef184bbafdb4ebe86501#diff-58f39591209025b16105c9f25a34c119332983a0d8cea7819b534d9d408324c4L329
// Credit to @Nicklason
import { EAuthSessionGuardType, EAuthTokenPlatformType, LoginSession } from 'steam-session';
import jwt from 'jsonwebtoken';
import DiscordBot from './DiscordBot';
import { Message as DiscordMessage } from 'discord.js';
Expand Down Expand Up @@ -65,8 +62,6 @@ export default class Bot {

readonly manager: TradeOfferManager;

session: LoginSession | null = null;

readonly community: SteamCommunity;

tradeOfferUrl: string;
Expand Down Expand Up @@ -839,13 +834,14 @@ export default class Bot {
let cookies: string[];

this.addListener(this.client, 'loggedOn', this.handler.onLoggedOn.bind(this.handler), false);
this.addListener(this.client, 'refreshToken', this.handler.onRefreshToken.bind(this.handler), false);
this.addAsyncListener(this.client, 'friendMessage', this.onMessage.bind(this), true);
this.addListener(this.client, 'friendRelationship', this.handler.onFriendRelationship.bind(this.handler), true);
this.addListener(this.client, 'groupRelationship', this.handler.onGroupRelationship.bind(this.handler), true);
this.addListener(this.client, 'newItems', this.onNewItems.bind(this), true);
this.addListener(this.client, 'webSession', this.onWebSession.bind(this), false);
this.addListener(this.client, 'steamGuard', this.onSteamGuard.bind(this), false);
this.addListener(this.client, 'error', this.onError.bind(this), false);
this.addAsyncListener(this.client, 'error', this.onError.bind(this), false);

this.addListener(this.community, 'sessionExpired', this.onSessionExpired.bind(this), false);
this.addListener(this.community, 'confKeyNeeded', this.onConfKeyNeeded.bind(this), false);
Expand Down Expand Up @@ -1373,113 +1369,7 @@ export default class Bot {
});
}

private async startSession(): Promise<string> {
this.session = new LoginSession(EAuthTokenPlatformType.SteamClient);
// will think about proxy later
//,{
// httpProxy: this.configService.getOrThrow<SteamAccountConfig>('steam').proxyUrl
// });

this.session.on('debug', (message: string) => {
log.debug(`Session debug: ${message}`);
});

const oldTokens = (await files.readFile(this.handler.getPaths.files.loginToken, true).catch(err => {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-member-access
log.warn(`Failed to read tokens: ${err.message}`);
return null;
})) as SteamTokens;

if (oldTokens !== null) {
// Figure out if the refresh token expired
const { refreshToken, accessToken } = oldTokens;

this.session.refreshToken = refreshToken;
this.session.accessToken = accessToken;

const decoded = jwt.decode(refreshToken, {
complete: true
});

if (decoded) {
const { exp } = decoded.payload as { exp: number };

if (exp < Date.now() / 1000) {
// Refresh token expired, log in again
log.debug('Refresh token expired, logging in again');
} else {
// Refresh token is still valid, use it
return refreshToken;
}
}
}

const result = await this.session.startWithCredentials({
accountName: this.options.steamAccountName,
password: this.options.steamPassword
});

if (result.actionRequired) {
const actions = result.validActions ?? [];

if (actions.length !== 1) {
throw new Error(`Unexpected number of valid actions: ${actions.length}`);
}

const action = actions[0];

if (action.type !== EAuthSessionGuardType.DeviceCode) {
throw new Error(`Unexpected action type: ${action.type}`);
}

await this.session.submitSteamGuardCode(SteamTotp.generateAuthCode(this.options.steamSharedSecret));
}

this.session.on('error', err => {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-member-access
log.warn(`Error in session: ${err.message}`);
});

/* eslint-disable @typescript-eslint/no-non-null-assertion */
return new Promise<void>((resolve, reject) => {
const handleAuth = () => {
log.debug('Session authenticated');
removeListeners();
resolve();
};

const handleTimeout = () => {
removeListeners();
reject(new Error('Login session timed out'));
};

const handleError = (err: Error) => {
removeListeners();
reject(err);
};

const removeListeners = () => {
this.session.removeListener('authenticated', handleAuth);
this.session.removeListener('timeout', handleTimeout);
this.session.removeListener('error', handleError);
};

this.session.once('authenticated', handleAuth);
this.session.once('error', handleError);
}).then(() => {
const refreshToken = this.session.refreshToken;

this.handler.onLoginToken({ refreshToken, accessToken: this.session.accessToken });

this.session.removeAllListeners();
this.session = null;

return refreshToken;
});
/* eslint-enable @typescript-eslint/no-non-null-assertion */
}

private async login(): Promise<void> {
private async login(refreshToken?: string): Promise<void> {
log.debug('Starting login attempt');
// loginKey: loginKey,
// private: true
Expand All @@ -1489,19 +1379,12 @@ export default class Bot {
this.handler.onLoginThrottle(wait);
}

const refreshToken = await this.startSession();

return new Promise((resolve, reject) => {
setTimeout(() => {
const listeners = this.client.listeners('error');

this.client.removeAllListeners('error');

const details = { refreshToken };

this.newLoginAttempt();
this.client.logOn(details);

const gotEvent = (): void => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Expand Down Expand Up @@ -1535,16 +1418,74 @@ export default class Bot {
this.client.removeListener('error', errorEvent);

log.debug('Did not get login response from Steam');
this.client.logOff();

reject(new Error('Did not get login response (Steam might be down)'));
}, 60 * 1000);

this.client.once('loggedOn', loggedOnEvent);
this.client.once('error', errorEvent);

let loginDetails: { refreshToken: string } | { accountName: string; password: string };

if (refreshToken) {
log.debug('Attempting to login to Steam with refresh token...');
loginDetails = { refreshToken };
} else {
log.debug('Attempting to login to Steam...');
loginDetails = {
accountName: this.options.steamAccountName,
password: this.options.steamPassword
};
}

this.newLoginAttempt();
this.client.logOn(loginDetails);
}, wait);
});
}

private calculateBackoff(delay: number, attempts: number): number {
return delay * Math.pow(2, attempts - 1) + Math.floor(Math.random() * 1000);
}

private async getRefreshToken(): Promise<string | null> {
const tokenPath = this.handler.getPaths.files.refreshToken;

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const refreshToken = (await files.readFile(tokenPath, false).catch(err => null)) as string;

if (!refreshToken) {
return null;
}

const decoded = jwt.decode(refreshToken, {
complete: true
});

if (!decoded) {
// Invalid token
return null;
}

const { exp } = decoded.payload as { exp: number };

if (exp < Date.now() / 1000) {
// Refresh token expired
return null;
}

return refreshToken;
}

private async deleteRefreshToken(): Promise<void> {
const tokenPath = this.handler.getPaths.files.refreshToken;

await files.writeFile(tokenPath, '', false).catch(() => {
// Ignore error
});
}

sendMessage(steamID: SteamID | string, message: string): void {
if (steamID instanceof SteamID && steamID.redirectAnswerTo) {
const origMessage = steamID.redirectAnswerTo;
Expand Down Expand Up @@ -1667,7 +1608,7 @@ export default class Bot {
});
}

private onError(err: CustomError): void {
private async onError(err: CustomError): Promise<void> {
if (err.eresult === EResult.LoggedInElsewhere) {
log.warn('Signed in elsewhere, stopping the bot...');
this.botManager.stop(err, false, true);
Expand All @@ -1682,7 +1623,9 @@ export default class Bot {

log.warn('Login session replaced, relogging...');

this.login().catch(err => {
await this.deleteRefreshToken();

this.login(await this.getRefreshToken()).catch(err => {
if (err) {
throw err;
}
Expand Down
5 changes: 0 additions & 5 deletions src/classes/BotManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,6 @@ export default class BotManager {
this.bot.client.setPersona(EPersonaState.Snooze);
this.bot.client.autoRelogin = false;

if (this.bot.session) {
this.bot.session.removeAllListeners();
this.bot.session.cancelLoginAttempt();
}

// Stop polling offers
this.bot.manager.pollInterval = -1;

Expand Down
4 changes: 2 additions & 2 deletions src/classes/Handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import SteamID from 'steamid';
import TradeOfferManager, { PollData, Meta, CustomError } from '@tf2autobot/tradeoffer-manager';
import Bot, { SteamTokens } from './Bot';
import Bot from './Bot';
import { Entry, PricesDataObject, PricesObject } from './Pricelist';
import { Blocked } from './MyHandler/interfaces';

Expand Down Expand Up @@ -47,7 +47,7 @@ export default abstract class Handler {
* Called when a new login key has been issued
* @param loginKey - The new login key
*/
abstract onLoginToken(loginToken: SteamTokens): void;
abstract onRefreshToken(token: string): void;

/**
* Called when a new trade offer is being processed
Expand Down
14 changes: 7 additions & 7 deletions src/classes/MyHandler/MyHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { keepMetalSupply, craftDuplicateWeapons, craftClassWeapons } from './uti
import { Blocked, BPTFGetUserInfo } from './interfaces';

import Handler, { OnRun } from '../Handler';
import Bot, { SteamTokens } from '../Bot';
import Bot from '../Bot';
import Pricelist, { Entry, PricesDataObject, PricesObject } from '../Pricelist';
import Commands from '../Commands/Commands';
import CartQueue from '../Carts/CartQueue';
Expand Down Expand Up @@ -493,19 +493,19 @@ export default class MyHandler extends Handler {
await this.commands.processMessage(steamID, message);
}

onLoginToken(loginToken: SteamTokens): void {
log.debug('New login key');
onRefreshToken(token: string): void {
log.debug('New refresh key');

files.writeFile(this.paths.files.loginToken, loginToken, true).catch(err => {
log.warn('Failed to save login token: ', err);
files.writeFile(this.paths.files.refreshToken, token, false).catch(err => {
log.warn('Failed to save refresh token: ', err);
});
}

onLoginError(err: CustomError): void {
if (err.eresult === EResult.AccessDenied) {
// Access denied during login
files.deleteFile(this.paths.files.loginToken).catch(err => {
log.warn('Failed to delete login token file: ', err);
files.deleteFile(this.paths.files.refreshToken).catch(err => {
log.warn('Failed to delete refresh token file: ', err);
});
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/resources/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import path from 'path';
import fs from 'fs';

interface FilePaths {
loginToken: string;
refreshToken: string;
pollData: string;
loginAttempts: string;
pricelist: string;
Expand Down Expand Up @@ -35,7 +35,7 @@ export default function genPaths(steamAccountName: string, maxPollDataSizeMB = 5

return {
files: {
loginToken: path.join(__dirname, `../../files/${steamAccountName}/loginToken.json`),
refreshToken: path.join(__dirname, `../../files/${steamAccountName}/refreshToken.txt`),
pollData: pollDataPath,
loginAttempts: path.join(__dirname, `../../files/${steamAccountName}/loginattempts.json`),
pricelist: path.join(__dirname, `../../files/${steamAccountName}/pricelist.json`),
Expand Down
Loading