mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +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>
177 lines
5.4 KiB
TypeScript
177 lines
5.4 KiB
TypeScript
/**
|
|
* The one place a login becomes a session: JWT, cookie, response body,
|
|
* audit line and the user_login internal event. Every login method ends here, so
|
|
* they all behave the same way.
|
|
*/
|
|
|
|
import { isExternalAccount } from "./external-account.js";
|
|
import type { Request, Response } from "express";
|
|
import { AuthManager } from "../utils/auth-manager.js";
|
|
import { authLogger } from "../utils/logger.js";
|
|
import { loginRateLimiter } from "../utils/login-rate-limiter.js";
|
|
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
|
|
import { parseUserAgent } from "../utils/user-agent-parser.js";
|
|
import { emitInternalEvent } from "../hosts/internal-events.js";
|
|
import {
|
|
createCurrentSettingsRepository,
|
|
createCurrentUserAuthRepository,
|
|
} from "../database/repositories/factory.js";
|
|
import type { UserRecord } from "../database/repositories/user-repository.js";
|
|
import type { ExternalSessionRef } from "./types.js";
|
|
|
|
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
|
export function isNativeAppRequest(req: Request): boolean {
|
|
return (
|
|
(req.get("User-Agent") || "").startsWith("Termix-Mobile/") ||
|
|
req.get("X-Electron-App") === "true"
|
|
);
|
|
}
|
|
|
|
/** Cookie lifetime: 30 days when remembered, else session_timeout_hours. */
|
|
async function sessionCookieMaxAge(rememberMe: boolean): Promise<number> {
|
|
if (rememberMe) return THIRTY_DAYS_MS;
|
|
const value = await createCurrentSettingsRepository().get(
|
|
"session_timeout_hours",
|
|
);
|
|
const hours = value ? parseInt(value, 10) || 24 : 24;
|
|
return hours * 60 * 60 * 1000;
|
|
}
|
|
|
|
export async function syncSharedCredentialsForUserRoles(
|
|
userId: string,
|
|
operation: string,
|
|
): Promise<void> {
|
|
try {
|
|
const { SharedHostSecretsManager } =
|
|
await import("../utils/shared-host-secrets-manager.js");
|
|
await SharedHostSecretsManager.getInstance().snapshotForUserRoles(userId);
|
|
const { SharedCredentialSecretsManager } =
|
|
await import("../utils/shared-credential-secrets-manager.js");
|
|
await SharedCredentialSecretsManager.getInstance().snapshotForUserRoles(
|
|
userId,
|
|
);
|
|
} catch (error) {
|
|
authLogger.warn("Failed to sync role shared host secrets", {
|
|
operation,
|
|
userId,
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
|
|
export interface IssueSessionOptions {
|
|
methodId: string;
|
|
rememberMe: boolean;
|
|
/** Username the rate limiter counted attempts under, to clear them. */
|
|
rateLimitUsername?: string;
|
|
externalSession?: ExternalSessionRef | null;
|
|
/** SSO logins keep desktop and mobile apps signed in for 30 days. */
|
|
longLivedForApps?: boolean;
|
|
/** Login policy cap on the session length. */
|
|
maxAgeSeconds?: number | null;
|
|
/** See SECOND_FACTOR_SETUP. */
|
|
restriction?: string | null;
|
|
idleTimeoutSeconds?: number | null;
|
|
}
|
|
|
|
export interface IssuedSession {
|
|
token: string;
|
|
maxAge: number;
|
|
body: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Mints the JWT and writes the audit line. The caller decides how the token
|
|
* reaches the client: a JSON body with a cookie, or a redirect.
|
|
*/
|
|
export async function issueSession(
|
|
req: Request,
|
|
user: UserRecord,
|
|
options: IssueSessionOptions,
|
|
): Promise<IssuedSession> {
|
|
const authManager = AuthManager.getInstance();
|
|
const deviceInfo = parseUserAgent(req);
|
|
|
|
const token = await authManager.generateJWTToken(user.id, {
|
|
rememberMe: options.rememberMe,
|
|
deviceType: deviceInfo.type,
|
|
deviceInfo: deviceInfo.deviceInfo,
|
|
externalSession: options.externalSession ?? null,
|
|
maxAgeSeconds: options.maxAgeSeconds ?? null,
|
|
restriction: options.restriction ?? null,
|
|
idleTimeoutSeconds: options.idleTimeoutSeconds ?? null,
|
|
});
|
|
|
|
if (options.rateLimitUsername) {
|
|
const clientIp = req.ip || req.socket?.remoteAddress || "unknown";
|
|
loginRateLimiter.resetAttempts(clientIp, options.rateLimitUsername);
|
|
}
|
|
|
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
await logAudit({
|
|
userId: user.id,
|
|
username: user.username,
|
|
action: "login",
|
|
resourceType: "session",
|
|
details: JSON.stringify({ method: options.methodId }),
|
|
ipAddress,
|
|
userAgent,
|
|
success: true,
|
|
});
|
|
emitInternalEvent("user_login", user.id, undefined, {
|
|
username: user.username,
|
|
ipAddress,
|
|
});
|
|
|
|
authLogger.success("User login successful", {
|
|
operation: "user_login_complete",
|
|
userId: user.id,
|
|
username: user.username,
|
|
method: options.methodId,
|
|
});
|
|
|
|
let maxAge =
|
|
options.longLivedForApps &&
|
|
(deviceInfo.type === "desktop" || deviceInfo.type === "mobile")
|
|
? THIRTY_DAYS_MS
|
|
: await sessionCookieMaxAge(options.rememberMe);
|
|
if (options.maxAgeSeconds) {
|
|
maxAge = Math.min(maxAge, options.maxAgeSeconds * 1000);
|
|
}
|
|
|
|
return {
|
|
token,
|
|
maxAge,
|
|
body: {
|
|
success: true,
|
|
is_admin: !!user.isAdmin,
|
|
username: user.username,
|
|
userId: user.id,
|
|
is_external: isExternalAccount(user),
|
|
// Any second factor; the name is what 2.8 clients read.
|
|
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
|
|
user.id,
|
|
),
|
|
...(options.restriction ? { restriction: options.restriction } : {}),
|
|
...(isNativeAppRequest(req) ? { token } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Sets the cookie and sends the body. */
|
|
export function sendSession(
|
|
req: Request,
|
|
res: Response,
|
|
session: IssuedSession,
|
|
): Response {
|
|
const authManager = AuthManager.getInstance();
|
|
return res
|
|
.cookie(
|
|
"jwt",
|
|
session.token,
|
|
authManager.getSecureCookieOptions(req, session.maxAge),
|
|
)
|
|
.json(session.body);
|
|
}
|