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

Add 2FA authentication #2

Merged
merged 3 commits into from
Mar 28, 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
34 changes: 31 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import * as prompt from '@inquirer/prompts';
import {encryptPassword, fetchVerification, login} from "./instagram";
import {
encryptPassword,
fetchVerification,
login,
TwoFactorInformation,
TwoFactorRequired,
VerificationData,
verify2FA
} from "./instagram";
import {ExitPromptError} from "@inquirer/prompts";


async function authenticate() {
async function authenticate(): Promise<string> {
const verification = await fetchVerification()

while (true) {
Expand All @@ -15,7 +23,27 @@ async function authenticate() {
try {
return await login({user, password: encryptedPassword, verification})
} catch (e) {
console.error((e as Error).message)
if (!(e instanceof TwoFactorRequired)) {
console.error((e as Error).message)
continue
}

return await twoFactor({info: (e as TwoFactorRequired).info, verification})
}
}
}

async function twoFactor({verification, info}: {
verification: VerificationData,
info: TwoFactorInformation
}): Promise<string> {
while (true) {
const code = await prompt.input({message: "Two factor authentication code: "})

try {
return await verify2FA({verification, code, info})
} catch (e) {
console.error(e.message)
}
}
}
Expand Down
74 changes: 64 additions & 10 deletions src/instagram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,20 @@ const crypto = globalThis.crypto
const encoder = new TextEncoder()

export class TwoFactorRequired extends Error {
constructor() {
info: TwoFactorInformation

constructor(info: TwoFactorInformation) {
super("Two factor authentication is enabled for this account.");
this.info = info
}
}

export interface TwoFactorInformation {
identifier: string,
user: string,
device: string
}

export interface InstagramEncryptionKey {
public: string,
id: number,
Expand Down Expand Up @@ -100,6 +109,20 @@ export async function encryptPassword({time, password, key, providedKey}: {
return {timestamp: parseInt(timeString, 10), cipher: btoa(converted.join(''))}
}

function getSessionId(response: Response): string {
const identifier = "sessionid="
const identify = (cookie: string) => cookie.startsWith(identifier)

return response.headers
.getSetCookie().find(identify)
.split(";").find(identify)
.substring(identifier.length)
}

function hasJsonBody(response: Response): boolean {
return response.headers.get("Content-Type").startsWith("application/json;")
}

export async function login({user, password, verification}: {
user: string,
password: EncryptedPassword,
Expand All @@ -122,14 +145,23 @@ export async function login({user, password, verification}: {
})

if (!response.ok) {
if (response.headers.get("Content-Type").startsWith("application/json;")) {
if (hasJsonBody(response)) {
const data = await response.json() as {
message?: string,
two_factor_required?: boolean
two_factor_required?: boolean,
two_factor_info?: {
username: string,
two_factor_identifier: string,
device_id: string
}
}

if (data.two_factor_required) {
throw new TwoFactorRequired()
throw new TwoFactorRequired({
user: data.two_factor_info.username,
identifier: data.two_factor_info.two_factor_identifier,
device: data.two_factor_info.device_id
})
}

throw new Error(data.message ?? "Login attempted failed.")
Expand All @@ -142,11 +174,33 @@ export async function login({user, password, verification}: {
throw new Error("Authentication failed.")
}

const identifier = "sessionid="
const identify = (cookie: string) => cookie.startsWith(identifier)
return getSessionId(response)
}

return response.headers
.getSetCookie().find(identify)
.split(";").find(identify)
.substring(identifier.length)
export async function verify2FA({verification, info, code}: {
info: TwoFactorInformation,
verification: VerificationData,
code: string
}): Promise<string> {
const body = new FormData()
body.set("username", info.user)
body.set("identifier", info.identifier)
body.set("verificationCode", code)

const response = await fetch("https://www.instagram.com/api/v1/web/accounts/login/ajax/two_factor/", {
method: "POST",
headers: {
"X-CSRFToken": verification.csrf,
"Sec-Fetch-Site": "same-origin",
"X-Mid": info.device,
},
body
})

if (!response.ok) {
const message = hasJsonBody(response) ? (await response.json()).message : await response.text()
throw Error(message ?? "Two factor authentication failed.")
}

return getSessionId(response)
}
146 changes: 95 additions & 51 deletions test/instagram.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import {
encryptPassword,
fetchVerification,
InstagramEncryptionKey,
login, TwoFactorRequired,
VerificationData
login, TwoFactorInformation, TwoFactorRequired,
VerificationData, verify2FA
} from "../src/instagram";

interface PasswordEncryption {
Expand Down Expand Up @@ -163,57 +163,66 @@ describe("Verification data fetcher", () => {
})
})

describe("Login request handler", () => {
const encryptedPassword: EncryptedPassword = {
cipher: btoa("cipher-text-as-base64"),
timestamp: 1234567890
}
function getJsonHeaders() {
const headers = new Headers()
headers.set("Content-Type", "application/json; charset=utf-8")

return headers
}

function getSessionHeaders() {
const id = "a-super-secret-session-id"

const headers = getJsonHeaders()
headers.set(
"set-cookie",
`sessionid=${id}; Domain=.instagram.com; expires=Tue, 25-Mar-2025 12:23:08 GMT; HttpOnly; Max-Age=31536000; Path=/; Secure`
)

return {id, headers}
}

function expectThrowsErrorWithMessage(request: Promise<any>, message: string|undefined = undefined) {
return Promise.all([
expect(request).rejects.toBeInstanceOf(Error),
expect(request).rejects.toStrictEqual(expect.objectContaining({message: message ?? expect.any(String)}))
])
}

const verification: VerificationData = {
key: instagramKey87,
csrf: "random-csrf-value"
describe("Login request handler", () => {
const loginData = {
user: "user",
password: {
cipher: btoa("cipher-text-as-base64"),
timestamp: 1234567890
} as EncryptedPassword,
verification: {
key: instagramKey87,
csrf: "random-csrf-value"
} as VerificationData
}

test("Returns session id on success", async () => {
const sessionId = "a-super-secret-session-id"

const headers = new Headers()
headers.set(
"set-cookie",
`sessionid=${sessionId}; Domain=.instagram.com; expires=Tue, 25-Mar-2025 12:23:08 GMT; HttpOnly; Max-Age=31536000; Path=/; Secure`
)
const {id, headers} = getSessionHeaders()

jest.spyOn(global, "fetch").mockImplementation(() => Promise.resolve({
ok: true,
json: () => Promise.resolve({authenticated: true}),
headers
} as Response))

const result = await login({
user: "user",
password: encryptedPassword,
verification
})

expect(result).toStrictEqual(sessionId)
return expect(login(loginData)).resolves.toStrictEqual(id)
})

describe("Throws on failed login", () => {
test.each([undefined, "Received error description"])("Message: %s", (message) => {
const headers = new Headers()
headers.set("Content-Type", "application/json; charset=utf-8")

jest.spyOn(global, "fetch").mockImplementation(() => Promise.resolve({
ok: false,
json: () => Promise.resolve({authenticated: false, message}),
headers
headers: getJsonHeaders()
} as Response))

return expect(login({
user: "user",
password: encryptedPassword,
verification
})).rejects.toStrictEqual(expect.objectContaining({message: message ?? expect.any(String)}))
return expectThrowsErrorWithMessage(login(loginData), message)
})
})

Expand All @@ -223,11 +232,7 @@ describe("Login request handler", () => {
json: () => Promise.resolve({authenticated: false}),
} as Response))

return expect(login({
user: "user",
password: encryptedPassword,
verification
})).rejects.toStrictEqual(expect.objectContaining({message: expect.any(String)}))
return expectThrowsErrorWithMessage(login(loginData))
})

test("Throws on failed request", () => {
Expand All @@ -242,27 +247,66 @@ describe("Login request handler", () => {
headers
} as Response))

return expect(login({
user: "user",
password: encryptedPassword,
verification
})).rejects.toStrictEqual(expect.objectContaining({message}))
return expectThrowsErrorWithMessage(login(loginData), message)
})

test("Throws if 2FA is required", () => {
const headers = new Headers()
headers.set("Content-Type", "application/json; charset=utf-8")
const info: TwoFactorInformation = {
device: "device-id",
identifier: "2fa-id",
user: "user"
}

jest.spyOn(global, "fetch").mockImplementation(() => Promise.resolve({
ok: false,
json: () => Promise.resolve({two_factor_required: true}),
json: () => Promise.resolve({
two_factor_required: true, two_factor_info: {
device_id: info.device,
two_factor_identifier: info.identifier,
username: info.user
}
}),
headers: getJsonHeaders()
} as Response))

const loginResult = login(loginData)

return Promise.all([
expect(loginResult).rejects.toBeInstanceOf(TwoFactorRequired),
expect(loginResult).rejects.toStrictEqual(expect.objectContaining({info}))
])
})
})

describe("Two factor authentication handler", () => {
const requestData = {
verification: {} as VerificationData,
info: {} as TwoFactorInformation,
code: "123456"
}

test("Returns session id on success", () => {
const {id, headers} = getSessionHeaders()

jest.spyOn(global, "fetch").mockImplementation(() => Promise.resolve({
ok: true,
headers
} as Response))

return expect(login({
user: "user",
password: encryptedPassword,
verification
})).rejects.toBeInstanceOf(TwoFactorRequired)
return expect(verify2FA(requestData)).resolves.toStrictEqual(id)
});

describe("Throws on failed authentication", () => {
test.each([undefined, "Received error description"])("Message: %s", (message) => {
jest.spyOn(global, "fetch").mockImplementation(() => Promise.resolve({
ok: false,
json: () => Promise.resolve({authenticated: false, message}),
headers: getJsonHeaders()
} as Response))

const verificationResult = verify2FA(requestData)

return expectThrowsErrorWithMessage(verificationResult, message)
})
})
})