mirror of
https://github.com/YuzuZensai/Termix.git
synced 2026-11-12 18:06:36 +00:00
* feat: shell controller, TabStage, layout and theme plugins with repair view * feat: move the classic layout into its own bundled plugin * feat: move color themes into a bundled themes plugin * fix: plugin tests and type checks work with a linked sdk * fix: home tab, layout switch and missing layout fallbacks for looks * chore: add classic layout and themes to the readme * chore: drop example plugin links from the plugin guide * feat: plugin filters by type, paged browse, hide plugins needing newer termix * feat: move desktop, status, import, audit and branding features into plugins * feat: host lock api so a plugin can load hosts from a config file (#1581) * chore: add hosts config to the plugin table * feat: login policy hook for plugins and optional https redirect (#1584) * chore: add login security to the plugin table * feat: api key socket tickets so a master can open remote sessions (#1654) * feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654) * chore: add sync to the plugin table * feat: move the sync screens and wizard out of core into the plugin (#1580) * fix: add a neutral linked desktop hook for plugins (#1580) * fix: show the linked account's name and admin flag in the app (#1580) * feat: ssh transport providers, core ssh relay and plugin folders api * feat: support FIDO2 security keys through ssh agent * feat: add companion panels beside host tabs * feat: mTLS client certificates for desktop server links * feat: native web view for plugins in the desktop app * fix: detect the desktop link window by Electron's default user agent Fixes #1675 * fix: changelog for desktop link fix * fix: keep text primary keys in the plugin data export * fix: ship sharp binaries for Windows arm64 builds * feat: client cert on the sync link types (sdk 1.0.18) * chore: update translations * feat: themed title bar for the desktop app (#1690) * chore: changelog for the themed title bar * fix: draw the title bar in the sidebar color * feat: per host layout for the terminal panel bar * chore: sdk 1.0.19 for the panel bar layout * chore: bundle all 43 plugins for 26.10.3 * chore: update translations --------- Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com> Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
485 lines
14 KiB
TypeScript
485 lines
14 KiB
TypeScript
/**
|
|
* The generic login surface: every registered login method and second factor
|
|
* is reachable here, core's and plugins' alike. The older per-method routes
|
|
* (/users/login, /users/oidc/*, /users/ldap/login, ...) stay as thin wrappers
|
|
* so existing clients keep working.
|
|
*/
|
|
|
|
import type { Request, Response, Router } from "express";
|
|
import { authLogger } from "../../utils/logger.js";
|
|
import { isExternalTokenCallback } from "../../utils/external-login-callback.js";
|
|
import { ensureCoreLoginProviders } from "../../auth/core-auth.js";
|
|
import {
|
|
readPendingLogin,
|
|
redirectWithLoginError,
|
|
respondWithLogin,
|
|
respondWithRedirectLogin,
|
|
sendLoginError,
|
|
verifySecondFactorAndRespond,
|
|
evaluateSecondFactors,
|
|
} from "../../auth/login-pipeline.js";
|
|
import {
|
|
getLoginMethod,
|
|
listLoginMethods,
|
|
type LoginMethod,
|
|
} from "../../auth/registry.js";
|
|
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
|
|
import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js";
|
|
import { rememberDeviceAllowed } from "../../auth/policy.js";
|
|
|
|
export interface PublicLoginMethod {
|
|
id: string;
|
|
pluginId: string;
|
|
kind: "redirect" | "form";
|
|
labelKey: string;
|
|
icon?: string;
|
|
instances: Array<{ id: string; label: string; autoStart?: boolean }>;
|
|
}
|
|
|
|
async function enabledInstances(method: LoginMethod) {
|
|
return (await method.describe!()).filter((instance) => instance.enabled);
|
|
}
|
|
|
|
/** External methods with at least one enabled instance. */
|
|
export async function listExternalLoginMethods(): Promise<LoginMethod[]> {
|
|
ensureCoreLoginProviders();
|
|
const found: LoginMethod[] = [];
|
|
for (const method of listLoginMethods()) {
|
|
if (!method.external) continue;
|
|
if (!method.describe) {
|
|
found.push(method);
|
|
continue;
|
|
}
|
|
try {
|
|
if ((await enabledInstances(method)).length > 0) found.push(method);
|
|
} catch {
|
|
// A method that cannot describe itself signs nobody in.
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/** The 2.8 `GET /users/sso-providers` shape, built from external methods. */
|
|
export async function listLegacySsoProviders(): Promise<
|
|
Array<{
|
|
id: number | string;
|
|
name: string;
|
|
type: string;
|
|
displayOrder: number;
|
|
}>
|
|
> {
|
|
const providers: Array<{
|
|
id: number | string;
|
|
name: string;
|
|
type: string;
|
|
displayOrder: number;
|
|
}> = [];
|
|
for (const method of await listExternalLoginMethods()) {
|
|
if (!method.describe) continue;
|
|
for (const instance of await enabledInstances(method)) {
|
|
const numeric = Number(instance.id);
|
|
providers.push({
|
|
id: Number.isInteger(numeric) ? numeric : instance.id,
|
|
name: instance.label,
|
|
type: instance.type ?? method.id,
|
|
displayOrder: providers.length,
|
|
});
|
|
}
|
|
}
|
|
return providers;
|
|
}
|
|
|
|
/** What the login screen may know: ids, labels and enabled instances. */
|
|
export async function listPublicLoginMethods(): Promise<PublicLoginMethod[]> {
|
|
ensureCoreLoginProviders();
|
|
const methods: PublicLoginMethod[] = [];
|
|
for (const method of listLoginMethods()) {
|
|
let instances: PublicLoginMethod["instances"] = [];
|
|
if (method.describe) {
|
|
try {
|
|
instances = (await method.describe())
|
|
.filter((instance) => instance.enabled)
|
|
.map((instance) => ({
|
|
id: instance.id,
|
|
label: instance.label,
|
|
...(instance.autoStart && method.kind === "redirect"
|
|
? { autoStart: true }
|
|
: {}),
|
|
}));
|
|
} catch (error) {
|
|
authLogger.warn("Login method could not describe itself", {
|
|
operation: "login_method_describe",
|
|
methodId: method.id,
|
|
error,
|
|
});
|
|
continue;
|
|
}
|
|
if (instances.length === 0) continue;
|
|
}
|
|
methods.push({
|
|
id: method.id,
|
|
pluginId: method.pluginId,
|
|
kind: method.kind,
|
|
labelKey: method.labelKey,
|
|
icon: method.icon,
|
|
instances,
|
|
});
|
|
}
|
|
return methods;
|
|
}
|
|
|
|
function instanceParam(req: Request): string | null {
|
|
const value = req.query.instance ?? req.body?.instanceId;
|
|
return typeof value === "string" && value ? value : null;
|
|
}
|
|
|
|
/**
|
|
* Trusted proxy login and external methods don't mix: the proxy already
|
|
* decided who the user is.
|
|
*/
|
|
function refusedByTrustedProxy(method: LoginMethod, res: Response): boolean {
|
|
if (!method.external || !isTrustedProxyAuthEnabled()) return false;
|
|
res.status(409).json({
|
|
error:
|
|
"External login is disabled while trusted proxy authentication is enabled",
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function methodOr404(req: Request, res: Response, id?: string) {
|
|
ensureCoreLoginProviders();
|
|
const method = getLoginMethod(id ?? String(req.params.methodId));
|
|
if (!method) {
|
|
res.status(404).json({ error: "Unknown login method" });
|
|
return null;
|
|
}
|
|
if (refusedByTrustedProxy(method, res)) return null;
|
|
return method;
|
|
}
|
|
|
|
/**
|
|
* A redirect method's callback, for core's route and for a plugin's own
|
|
* public route through ctx.auth.completeRedirectLogin.
|
|
*/
|
|
export async function handleRedirectCallback(
|
|
method: LoginMethod,
|
|
req: Request,
|
|
res: Response,
|
|
): Promise<void> {
|
|
if (refusedByTrustedProxy(method, res)) return;
|
|
if (!method.callback) {
|
|
res.status(400).json({ error: "Not a redirect login method" });
|
|
return;
|
|
}
|
|
let identity: VerifiedIdentity;
|
|
try {
|
|
identity = await method.callback(req as never);
|
|
} catch (error) {
|
|
const returnTo = (error as { returnTo?: string }).returnTo;
|
|
if (returnTo) return redirectWithLoginError(res, returnTo, error);
|
|
sendLoginError(res, error);
|
|
return;
|
|
}
|
|
await respondWithRedirectLogin(
|
|
req,
|
|
res,
|
|
identity,
|
|
{ methodId: method.id, rememberMe: !!identity.rememberMe },
|
|
isExternalTokenCallback,
|
|
);
|
|
}
|
|
|
|
/** Starts a redirect method, answering with its URL. */
|
|
export async function startRedirectLogin(
|
|
req: Request,
|
|
res: Response,
|
|
methodId: string,
|
|
instanceId: string | null,
|
|
): Promise<void> {
|
|
const method = methodOr404(req, res, methodId);
|
|
if (!method) return;
|
|
if (method.kind !== "redirect" || !method.start) {
|
|
res.status(400).json({ error: "Not a redirect login method" });
|
|
return;
|
|
}
|
|
try {
|
|
const result = await method.start(req as never, instanceId);
|
|
// auth_url is the name 2.8 clients read.
|
|
res.json({ ...result, auth_url: result.redirectUrl });
|
|
} catch (error) {
|
|
sendLoginError(res, error);
|
|
}
|
|
}
|
|
|
|
/** Runs a form method and answers with a session or a second-factor step. */
|
|
export async function verifyFormLogin(
|
|
req: Request,
|
|
res: Response,
|
|
methodId: string,
|
|
instanceId: string | null,
|
|
): Promise<void> {
|
|
const method = methodOr404(req, res, methodId);
|
|
if (!method) return;
|
|
if (method.kind !== "form" || !method.verify) {
|
|
res.status(400).json({ error: "Not a form login method" });
|
|
return;
|
|
}
|
|
try {
|
|
const identity = await method.verify(req as never, instanceId);
|
|
await respondWithLogin(req, res, identity, {
|
|
methodId: method.id,
|
|
rememberMe: !!identity.rememberMe || !!req.body?.rememberMe,
|
|
});
|
|
} catch (error) {
|
|
if (!(error instanceof LoginMethodError)) {
|
|
authLogger.error("Form login failed", error, { methodId: method.id });
|
|
}
|
|
sendLoginError(res, error);
|
|
}
|
|
}
|
|
|
|
export function registerAuthRoutes(router: Router): void {
|
|
/**
|
|
* @openapi
|
|
* /users/auth/methods:
|
|
* get:
|
|
* summary: List login methods
|
|
* description: Public. The login methods with at least one enabled instance, for the login screen. Carries ids and labels only, plus rememberDevice, false when the login policy turned remember me off.
|
|
* tags:
|
|
* - Auth
|
|
* responses:
|
|
* 200:
|
|
* description: Login methods.
|
|
*/
|
|
router.get("/auth/methods", async (_req, res) => {
|
|
try {
|
|
res.json({
|
|
methods: await listPublicLoginMethods(),
|
|
rememberDevice: rememberDeviceAllowed(),
|
|
});
|
|
} catch (error) {
|
|
authLogger.error("Failed to list login methods", error);
|
|
res.status(500).json({ error: "Failed to list login methods" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/auth/second-factor/{factorId}/challenge:
|
|
* post:
|
|
* summary: Start a second factor
|
|
* description: Returns whatever the factor needs the client to have before the user answers, for a login waiting on a second factor.
|
|
* tags:
|
|
* - Auth
|
|
* parameters:
|
|
* - in: path
|
|
* name: factorId
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Challenge data.
|
|
* 401:
|
|
* description: No pending login.
|
|
* 404:
|
|
* description: Factor not enabled for this user.
|
|
*/
|
|
router.post("/auth/second-factor/:factorId/challenge", async (req, res) => {
|
|
try {
|
|
const lookup = await readPendingLogin(req);
|
|
if (!lookup) {
|
|
return res.status(401).json({ error: "Invalid temporary token" });
|
|
}
|
|
const { required } = await evaluateSecondFactors(lookup.userId);
|
|
const factor = required.find(
|
|
(candidate) => candidate.id === req.params.factorId,
|
|
);
|
|
if (!factor) {
|
|
return res.status(404).json({ error: "Second factor not enabled" });
|
|
}
|
|
res.json({
|
|
challenge: (await factor.challenge?.(lookup.userId)) ?? null,
|
|
});
|
|
} catch (error) {
|
|
sendLoginError(res, error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/auth/second-factor/{factorId}/verify:
|
|
* post:
|
|
* summary: Finish a second factor
|
|
* description: Checks the user's answer for a login waiting on a second factor and issues the session. The pending token comes from temp_token in the body or the pending-login cookie.
|
|
* tags:
|
|
* - Auth
|
|
* parameters:
|
|
* - in: path
|
|
* name: factorId
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Login successful.
|
|
* 401:
|
|
* description: Wrong answer or no pending login.
|
|
* 403:
|
|
* description: An enrolled factor is unavailable.
|
|
* 429:
|
|
* description: Too many attempts.
|
|
*/
|
|
router.post("/auth/second-factor/:factorId/verify", async (req, res) => {
|
|
try {
|
|
await verifySecondFactorAndRespond(req, res, String(req.params.factorId));
|
|
} catch (error) {
|
|
sendLoginError(res, error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/totp/verify-login:
|
|
* post:
|
|
* summary: Finish a login with a second factor (2.8 route)
|
|
* description: Kept for clients built against 2.8, such as Termix-Mobile. Verifies the pending login against the factor named in `factor`, or the user's first enrolled factor, the same way as /users/auth/second-factor/{factorId}/verify.
|
|
* tags:
|
|
* - Auth
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* temp_token:
|
|
* type: string
|
|
* totp_code:
|
|
* type: string
|
|
* factor:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Login finished.
|
|
* 400:
|
|
* description: Token and code are required.
|
|
* 401:
|
|
* description: Invalid temporary token or code.
|
|
*/
|
|
router.post("/totp/verify-login", async (req, res) => {
|
|
if (!req.body?.temp_token || !req.body?.totp_code) {
|
|
return res.status(400).json({ error: "Token and code are required" });
|
|
}
|
|
try {
|
|
await verifySecondFactorAndRespond(
|
|
req,
|
|
res,
|
|
typeof req.body.factor === "string" ? req.body.factor : undefined,
|
|
);
|
|
} catch (error) {
|
|
sendLoginError(res, error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/auth/{methodId}/start:
|
|
* get:
|
|
* summary: Start a redirect login
|
|
* description: Returns the URL to send the browser to for a redirect login method, such as an SSO provider.
|
|
* tags:
|
|
* - Auth
|
|
* parameters:
|
|
* - in: path
|
|
* name: methodId
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* - in: query
|
|
* name: instance
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Redirect URL.
|
|
* 404:
|
|
* description: Unknown login method.
|
|
*/
|
|
router.get("/auth/:methodId/start", (req, res) =>
|
|
startRedirectLogin(
|
|
req,
|
|
res,
|
|
String(req.params.methodId),
|
|
instanceParam(req),
|
|
),
|
|
);
|
|
|
|
const callback = async (req: Request, res: Response) => {
|
|
const method = methodOr404(req, res);
|
|
if (!method) return;
|
|
await handleRedirectCallback(method, req, res);
|
|
};
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/auth/{methodId}/callback:
|
|
* get:
|
|
* summary: Redirect login callback
|
|
* description: Where a redirect login method's provider sends the browser back. Redirects to the app with a session, a second-factor step, or an error.
|
|
* tags:
|
|
* - Auth
|
|
* parameters:
|
|
* - in: path
|
|
* name: methodId
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 302:
|
|
* description: Back to the app.
|
|
* post:
|
|
* summary: Redirect login callback (form post)
|
|
* description: Same as the GET form, for providers that post their answer.
|
|
* tags:
|
|
* - Auth
|
|
* parameters:
|
|
* - in: path
|
|
* name: methodId
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 302:
|
|
* description: Back to the app.
|
|
*/
|
|
router.get("/auth/:methodId/callback", callback);
|
|
router.post("/auth/:methodId/callback", callback);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/auth/{methodId}/verify:
|
|
* post:
|
|
* summary: Form login
|
|
* description: Checks what the user typed for a form login method, then issues a session or asks for a second factor.
|
|
* tags:
|
|
* - Auth
|
|
* parameters:
|
|
* - in: path
|
|
* name: methodId
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Login successful, or a second factor is required.
|
|
* 401:
|
|
* description: Wrong credentials.
|
|
* 404:
|
|
* description: Unknown login method.
|
|
*/
|
|
router.post("/auth/:methodId/verify", (req, res) =>
|
|
verifyFormLogin(req, res, String(req.params.methodId), instanceParam(req)),
|
|
);
|
|
}
|