refactor: auto retry the auth flow

This commit is contained in:
Kevin Yue 2023-08-31 21:18:30 +08:00
parent 3098d1170f
commit 3674a28dee
12 changed files with 135 additions and 33 deletions

View File

@ -1,13 +1,19 @@
import { atom } from "jotai"; import { atom } from "jotai";
import authService from "../services/authService"; import authService from "../services/authService";
import portalService, { Prelogin } from "../services/portalService"; import portalService, {
import { loginPortalAtom } from "./loginPortal"; Prelogin,
SamlPrelogin,
} from "../services/portalService";
import logger from "../utils/logger";
import { AbnormalPortalConfigError, loginPortalAtom } from "./loginPortal";
import { notifyErrorAtom } from "./notification"; import { notifyErrorAtom } from "./notification";
import { launchPasswordLoginAtom } from "./passwordLogin"; import { launchPasswordLoginAtom } from "./passwordLogin";
import { currentPortalDataAtom, portalAddressAtom } from "./portal"; import { currentPortalDataAtom, portalAddressAtom } from "./portal";
import { launchSamlLoginAtom, retrySamlLoginAtom } from "./samlLogin"; import { launchSamlLoginAtom, retrySamlLoginAtom } from "./samlLogin";
import { isProcessingAtom, statusAtom } from "./status"; import { isProcessingAtom, statusAtom } from "./status";
const MAX_ATTEMPTS = 10;
/** /**
* Connect to the portal, workflow: * Connect to the portal, workflow:
* 1. Portal prelogin to get the prelogin data * 1. Portal prelogin to get the prelogin data
@ -31,10 +37,10 @@ export const connectPortalAtom = atom(
try { try {
set(statusAtom, "prelogin"); set(statusAtom, "prelogin");
const prelogin = await portalService.prelogin(portal); let prelogin = await portalService.prelogin(portal);
const isProcessing = get(isProcessingAtom); const isProcessing = get(isProcessingAtom);
if (!isProcessing) { if (!isProcessing) {
console.info("Request cancelled"); logger.info("Operation cancelled");
return; return;
} }
@ -44,7 +50,31 @@ export const connectPortalAtom = atom(
} catch { } catch {
// Otherwise, login with SAML or the password // Otherwise, login with SAML or the password
if (prelogin.isSamlAuth) { if (prelogin.isSamlAuth) {
await set(launchSamlLoginAtom, prelogin); let attemptCount = 0;
while (true) {
try {
attemptCount++;
logger.info(
`(${attemptCount}/${MAX_ATTEMPTS}) Launching SAML login...`
);
await set(launchSamlLoginAtom, prelogin as SamlPrelogin);
break;
} catch (err) {
if (attemptCount >= MAX_ATTEMPTS) {
throw err;
}
if (err instanceof AbnormalPortalConfigError) {
logger.info(
`Got abnormal portal config: ${err.message}, retrying...`
);
prelogin = await portalService.prelogin(portal);
continue;
} else {
throw err;
}
}
}
} else { } else {
set(launchPasswordLoginAtom, prelogin); set(launchPasswordLoginAtom, prelogin);
} }

View File

@ -1,5 +1,6 @@
import { atom } from "jotai"; import { atom } from "jotai";
import gatewayService from "../services/gatewayService"; import gatewayService from "../services/gatewayService";
import logger from "../utils/logger";
import { isProcessingAtom, statusAtom } from "./status"; import { isProcessingAtom, statusAtom } from "./status";
import { connectVpnAtom } from "./vpn"; import { connectVpnAtom } from "./vpn";
@ -19,6 +20,7 @@ export const loginGatewayAtom = atom(
set(statusAtom, "gateway-login"); set(statusAtom, "gateway-login");
let token: string; let token: string;
try { try {
logger.info(`Logging in to gateway ${gateway}...`);
token = await gatewayService.login(gateway, credential); token = await gatewayService.login(gateway, credential);
} catch (err) { } catch (err) {
throw new Error("Failed to login to gateway"); throw new Error("Failed to login to gateway");
@ -26,7 +28,7 @@ export const loginGatewayAtom = atom(
const isProcessing = get(isProcessingAtom); const isProcessing = get(isProcessingAtom);
if (!isProcessing) { if (!isProcessing) {
console.info("Request cancelled"); logger.info("Operation cancelled");
return; return;
} }

View File

@ -4,11 +4,23 @@ import portalService, {
PortalCredential, PortalCredential,
Prelogin, Prelogin,
} from "../services/portalService"; } from "../services/portalService";
import logger from "../utils/logger";
import { redact } from "../utils/redact";
import { selectedGatewayAtom } from "./gateway"; import { selectedGatewayAtom } from "./gateway";
import { loginGatewayAtom } from "./loginGateway"; import { loginGatewayAtom } from "./loginGateway";
import { portalAddressAtom, updatePortalDataAtom } from "./portal"; import { portalAddressAtom, updatePortalDataAtom } from "./portal";
import { isProcessingAtom, statusAtom } from "./status"; import { isProcessingAtom, statusAtom } from "./status";
/**
* The error thrown when the portal config is abnormal, can back to normal after retrying
*/
export class AbnormalPortalConfigError extends Error {
constructor(message: string) {
super(message);
this.name = "AbnormalPortalConfigError";
}
}
// Indicates whether the portal config is being fetched // Indicates whether the portal config is being fetched
// This is mainly used to show the loading indicator in the password login form // This is mainly used to show the loading indicator in the password login form
const portalConfigLoadingAtom = atom(false); const portalConfigLoadingAtom = atom(false);
@ -42,23 +54,30 @@ export const loginPortalAtom = atom(
try { try {
portalConfig = await portalService.fetchConfig(portalAddress, credential); portalConfig = await portalService.fetchConfig(portalAddress, credential);
configFetched?.(); configFetched?.();
} catch (err) {
const isProcessing = get(isProcessingAtom);
if (!isProcessing) {
logger.info("Operation cancelled");
return;
}
throw err;
} finally { } finally {
set(portalConfigLoadingAtom, false); set(portalConfigLoadingAtom, false);
} }
const isProcessing = get(isProcessingAtom); const isProcessing = get(isProcessingAtom);
if (!isProcessing) { if (!isProcessing) {
console.info("Request cancelled"); logger.info("Operation cancelled");
return; return;
} }
const { gateways, userAuthCookie, prelogonUserAuthCookie } = portalConfig; const { gateways, userAuthCookie, prelogonUserAuthCookie } = portalConfig;
if (!gateways.length) { if (!gateways.length) {
throw new Error("No gateway found"); throw new AbnormalPortalConfigError("No gateway found");
} }
if (userAuthCookie === "empty" || prelogonUserAuthCookie === "empty") { if (userAuthCookie === "empty" || prelogonUserAuthCookie === "empty") {
throw new Error("Got empty user auth cookie"); throw new AbnormalPortalConfigError("Empty user auth cookie");
} }
// Here, we have got the portal config successfully, refresh the cached portal data // Here, we have got the portal config successfully, refresh the cached portal data
@ -87,6 +106,8 @@ export const loginPortalAtom = atom(
preferredGateway: previousSelectedGateway, preferredGateway: previousSelectedGateway,
}); });
logger.info(`Found the preferred gateway: ${name} (${redact(address)})`);
// Log in to the gateway // Log in to the gateway
await set(loginGatewayAtom, address, { await set(loginGatewayAtom, address, {
user: credential.user, user: credential.user,

View File

@ -1,10 +1,11 @@
import { atom } from "jotai"; import { atom } from "jotai";
import authService, { AuthData } from "../services/authService"; import authService, { AuthData } from "../services/authService";
import portalService, { SamlPrelogin } from "../services/portalService"; import portalService, { SamlPrelogin } from "../services/portalService";
import logger from "../utils/logger";
import { redact } from "../utils/redact";
import { loginPortalAtom } from "./loginPortal"; import { loginPortalAtom } from "./loginPortal";
import { clearCookiesAtom, portalAddressAtom } from "./portal"; import { clearCookiesAtom, portalAddressAtom } from "./portal";
import { statusAtom } from "./status"; import { statusAtom } from "./status";
import { unwrap } from "./unwrap";
export const launchSamlLoginAtom = atom( export const launchSamlLoginAtom = atom(
null, null,
@ -38,7 +39,11 @@ export const launchSamlLoginAtom = atom(
"prelogin-cookie": authData.prelogin_cookie, "prelogin-cookie": authData.prelogin_cookie,
"portal-userauthcookie": authData.portal_userauthcookie, "portal-userauthcookie": authData.portal_userauthcookie,
}; };
logger.info(
`SAML login succeeded, prelogin-cookie: ${redact(
authData.prelogin_cookie
)}, portal-userauthcookie: ${redact(authData.portal_userauthcookie)}`
);
await set(loginPortalAtom, credential, prelogin); await set(loginPortalAtom, credential, prelogin);
} }
); );

View File

@ -1,5 +1,7 @@
import { atom } from "jotai"; import { atom } from "jotai";
import vpnService from "../services/vpnService"; import vpnService from "../services/vpnService";
import logger from "../utils/logger";
import { redact } from "../utils/redact";
import { notifyErrorAtom } from "./notification"; import { notifyErrorAtom } from "./notification";
import { statusAtom } from "./status"; import { statusAtom } from "./status";
@ -8,6 +10,11 @@ export const connectVpnAtom = atom(
async (_get, set, vpnAddress: string, token: string) => { async (_get, set, vpnAddress: string, token: string) => {
try { try {
set(statusAtom, "connecting"); set(statusAtom, "connecting");
logger.info(
`Connecting to VPN ${redact(vpnAddress)} with token ${redact(
token
)} ...`
);
await vpnService.connect(vpnAddress, token); await vpnService.connect(vpnAddress, token);
set(statusAtom, "connected"); set(statusAtom, "connected");
} catch (err) { } catch (err) {
@ -20,6 +27,7 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const disconnectVpnAtom = atom(null, async (get, set) => { export const disconnectVpnAtom = atom(null, async (get, set) => {
try { try {
set(statusAtom, "disconnecting"); set(statusAtom, "disconnecting");
logger.info("Disconnecting from VPN...");
await vpnService.disconnect(); await vpnService.disconnect();
// Sleep a short time, so that the client can receive the service's disconnected event. // Sleep a short time, so that the client can receive the service's disconnected event.
await sleep(100); await sleep(100);

View File

@ -7,7 +7,7 @@ export default function useGlobalTheme() {
() => () =>
createTheme({ createTheme({
palette: { palette: {
mode: prefersDarkMode ? "light" : "light", mode: prefersDarkMode ? "dark" : "light",
}, },
components: { components: {
MuiButton: { MuiButton: {

View File

@ -6,6 +6,7 @@ import { useSnackbar } from "notistack";
import { useState } from "react"; import { useState } from "react";
import { saveSettingsAtom } from "../../atoms/settings"; import { saveSettingsAtom } from "../../atoms/settings";
import settingsService, { TabValue } from "../../services/settingsService"; import settingsService, { TabValue } from "../../services/settingsService";
import logger from "../../utils/logger";
import OpenConnect from "./OpenConnect"; import OpenConnect from "./OpenConnect";
import OpenSSL from "./OpenSSL"; import OpenSSL from "./OpenSSL";
import Simulation from "./Simulation"; import Simulation from "./Simulation";
@ -33,7 +34,7 @@ export default function SettingsPanel() {
enqueueSnackbar("Settings saved", { variant: "success" }); enqueueSnackbar("Settings saved", { variant: "success" });
await closeWindow(); await closeWindow();
} catch (err) { } catch (err) {
console.warn("Failed to save settings", err); logger.warn(`Failed to save settings, ${err}`);
enqueueSnackbar("Failed to save settings", { variant: "error" }); enqueueSnackbar("Failed to save settings", { variant: "error" });
} }
}; };

View File

@ -17,7 +17,7 @@ class AuthService {
private async init() { private async init() {
await listen("auth-error", (evt) => { await listen("auth-error", (evt) => {
console.error("auth-error", evt); console.warn("auth-error", evt);
this.authErrorCallback?.(); this.authErrorCallback?.();
}); });
} }

View File

@ -1,8 +1,9 @@
import { Body, ResponseType, fetch } from "@tauri-apps/api/http"; import { Body, ResponseType, fetch } from "@tauri-apps/api/http";
import ErrorWithTitle from "../utils/ErrorWithTitle"; import ErrorWithTitle from "../utils/ErrorWithTitle";
import logger from "../utils/logger";
import { parseXml } from "../utils/parseXml"; import { parseXml } from "../utils/parseXml";
import { Gateway } from "./types";
import settingsService from "./settingsService"; import settingsService from "./settingsService";
import { Gateway } from "./types";
export type SamlPrelogin = { export type SamlPrelogin = {
isSamlAuth: true; isSamlAuth: true;
@ -43,6 +44,17 @@ class PortalService {
let response; let response;
try { try {
const body = Body.form({
tmp: "tmp",
clientVer: "4100",
clientos: clientOS,
"os-version": osVersion,
"ipv6-support": "yes",
"default-browser": "0",
"cas-support": "yes",
// "host-id": "TODO, mac address?",
});
response = await fetch<string>(preloginUrl, { response = await fetch<string>(preloginUrl, {
method: "POST", method: "POST",
headers: { headers: {
@ -52,16 +64,7 @@ class PortalService {
query: { query: {
"kerberos-support": "yes", "kerberos-support": "yes",
}, },
body: Body.form({ body,
tmp: "tmp",
clientVer: "4100",
clientos: clientOS,
"os-version": osVersion,
"ipv6-support": "yes",
"default-browser": "0",
"cas-support": "yes",
// "host-id": "TODO, mac address?",
}),
}); });
} catch (err) { } catch (err) {
if ( if (
@ -73,7 +76,7 @@ class PortalService {
"Unsafe Legacy Renegotiation disabled" "Unsafe Legacy Renegotiation disabled"
); );
} }
console.error("prelogin error", err); logger.warn(`Failed to fetch prelogin: ${err}`);
throw new Error("Network error"); throw new Error("Network error");
} }
@ -138,7 +141,7 @@ class PortalService {
prot: "https:", prot: "https:",
inputStr: "", inputStr: "",
jnlpReady: "jnlpReady", jnlpReady: "jnlpReady",
computer: "Linux", // TODO computer: clientOS, // TODO
clientos: clientOS, clientos: clientOS,
ok: "Login", ok: "Login",
direct: "yes", direct: "yes",
@ -165,7 +168,6 @@ class PortalService {
}); });
if (!response.ok) { if (!response.ok) {
console.error(response);
throw new Error(`Failed to fetch portal config: ${response.status}`); throw new Error(`Failed to fetch portal config: ${response.status}`);
} }

View File

@ -1,9 +1,7 @@
import { tempdir } from "@tauri-apps/api/os";
import { UserAttentionType, WebviewWindow } from "@tauri-apps/api/window"; import { UserAttentionType, WebviewWindow } from "@tauri-apps/api/window";
import invokeCommand from "../utils/invokeCommand"; import invokeCommand from "../utils/invokeCommand";
import logger from "../utils/logger";
import { appStorage } from "./storageService"; import { appStorage } from "./storageService";
import { fs } from "@tauri-apps/api";
import { Command } from "@tauri-apps/api/shell";
export type TabValue = "simulation" | "openssl" | "openconnect"; export type TabValue = "simulation" | "openssl" | "openconnect";
const SETTINGS_WINDOW_LABEL = "settings"; const SETTINGS_WINDOW_LABEL = "settings";
@ -136,13 +134,15 @@ async function getOpenconnectConfig(): Promise<string> {
const content = await invokeCommand<string>("openconnect_config"); const content = await invokeCommand<string>("openconnect_config");
return content; return content;
} catch (e) { } catch (e) {
console.error(e); logger.warn(`Failed to read /etc/gpservice/gp.conf: ${e}`);
return "# Failed to read /etc/gpservice/gp.conf"; return "# Failed to read /etc/gpservice/gp.conf";
} }
} }
async function updateOpenconnectConfig(content: string) { async function updateOpenconnectConfig(content: string) {
const exitCode = await invokeCommand("update_openconnect_config", { content }); const exitCode = await invokeCommand("update_openconnect_config", {
content,
});
if (exitCode) { if (exitCode) {
throw new Error(`Failed to update openconnect config: ${exitCode}`); throw new Error(`Failed to update openconnect config: ${exitCode}`);
} }

22
gpgui/src/utils/logger.ts Normal file
View File

@ -0,0 +1,22 @@
import * as log from "tauri-plugin-log-api";
const methods = ["trace", "debug", "info", "warn", "error"] as const;
type Logger = {
[key in (typeof methods)[number]]: (
message: string,
options?: log.LogOptions
) => Promise<void>;
};
function createLogger() {
const logger: Logger = {} as Logger;
for (const method of methods) {
logger[method] = async (message, options) => {
console[method](message);
await log[method](message, options);
};
}
return logger;
}
export default createLogger();

11
gpgui/src/utils/redact.ts Normal file
View File

@ -0,0 +1,11 @@
export function redact(data?: string) {
if (!data) {
return `<${data}>`;
}
if (data.length <= 8) {
return `<${data.replace(/./g, "*")}>`;
}
return `<${data.slice(0, 2)}...${data.slice(-2)}>`;
}