mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
fix: oidc, global default, and remember me issues
This commit is contained in:
@@ -815,6 +815,13 @@ router.get("/oidc/authorize", async (req, res) => {
|
||||
backendCallbackUri: backendCallbackUri,
|
||||
});
|
||||
|
||||
authLogger.info(
|
||||
`\n${"=".repeat(68)}\n` +
|
||||
` OIDC CALLBACK URL - Register this in your OAuth provider:\n` +
|
||||
` ${backendCallbackUri}\n` +
|
||||
`${"=".repeat(68)}`,
|
||||
);
|
||||
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
let config;
|
||||
|
||||
@@ -1526,9 +1533,10 @@ router.post("/login", async (req, res) => {
|
||||
if (userRecord.totpEnabled) {
|
||||
const deviceFingerprint = generateDeviceFingerprint(deviceInfo);
|
||||
|
||||
const isTrusted = rememberMe
|
||||
? await authManager.isTrustedDevice(userRecord.id, deviceFingerprint)
|
||||
: false;
|
||||
const isTrusted = await authManager.isTrustedDevice(
|
||||
userRecord.id,
|
||||
deviceFingerprint,
|
||||
);
|
||||
|
||||
if (isTrusted) {
|
||||
authLogger.info("TOTP bypassed for trusted device", {
|
||||
|
||||
@@ -1001,17 +1001,21 @@ class PollingManager {
|
||||
}
|
||||
}
|
||||
|
||||
refreshAllPolling(): void {
|
||||
async refreshAllPolling(): Promise<void> {
|
||||
const hostsToRefresh: Array<{
|
||||
host: SSHHostWithCredentials;
|
||||
viewerUserId?: string;
|
||||
}> = [];
|
||||
|
||||
for (const [hostId, config] of this.pollingConfigs.entries()) {
|
||||
hostsToRefresh.push({
|
||||
host: config.host,
|
||||
viewerUserId: config.viewerUserId,
|
||||
});
|
||||
const status = this.statusStore.get(hostId);
|
||||
|
||||
if (!status || status.status === "online") {
|
||||
hostsToRefresh.push({
|
||||
host: config.host,
|
||||
viewerUserId: config.viewerUserId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const hostId of this.pollingConfigs.keys()) {
|
||||
@@ -1019,8 +1023,10 @@ class PollingManager {
|
||||
}
|
||||
|
||||
for (const { host, viewerUserId } of hostsToRefresh) {
|
||||
this.startPollingForHost(host, { statusOnly: true, viewerUserId });
|
||||
await this.startPollingForHost(host, { statusOnly: true, viewerUserId });
|
||||
}
|
||||
|
||||
const skipped = this.pollingConfigs.size - hostsToRefresh.length;
|
||||
}
|
||||
|
||||
registerViewer(hostId: number, sessionId: string, userId: string): void {
|
||||
@@ -3206,16 +3212,21 @@ app.post("/global-settings", requireAdmin, async (req, res) => {
|
||||
.run(String(metricsInterval));
|
||||
}
|
||||
|
||||
// Refresh all active polling to apply new intervals immediately
|
||||
pollingManager.refreshAllPolling();
|
||||
await pollingManager.refreshAllPolling();
|
||||
|
||||
res.json({ success: true });
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Settings updated and polling refreshed",
|
||||
});
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to save global settings", {
|
||||
operation: "global_settings_save_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to save global settings" });
|
||||
res.status(500).json({
|
||||
error: "Failed to save global settings",
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ class AuthManager {
|
||||
const sessionDurationMs =
|
||||
deviceType === "desktop" || deviceType === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 2 * 60 * 60 * 1000;
|
||||
: 24 * 60 * 60 * 1000;
|
||||
|
||||
const authenticated = await this.userCrypto.authenticateOIDCUser(
|
||||
userId,
|
||||
@@ -121,7 +121,7 @@ class AuthManager {
|
||||
const sessionDurationMs =
|
||||
deviceType === "desktop" || deviceType === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 2 * 60 * 60 * 1000;
|
||||
: 24 * 60 * 60 * 1000;
|
||||
|
||||
const authenticated = await this.userCrypto.authenticateUser(
|
||||
userId,
|
||||
@@ -154,9 +154,8 @@ class AuthManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const { getSqlite, saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
const { getSqlite, saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
|
||||
const sqlite = getSqlite();
|
||||
|
||||
@@ -171,9 +170,8 @@ class AuthManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const { CredentialSystemEncryptionMigration } = await import(
|
||||
"./credential-system-encryption-migration.js"
|
||||
);
|
||||
const { CredentialSystemEncryptionMigration } =
|
||||
await import("./credential-system-encryption-migration.js");
|
||||
const credMigration = new CredentialSystemEncryptionMigration();
|
||||
const credResult = await credMigration.migrateUserCredentials(userId);
|
||||
|
||||
@@ -213,10 +211,10 @@ class AuthManager {
|
||||
if (options.rememberMe) {
|
||||
expiresIn = "30d";
|
||||
} else {
|
||||
expiresIn = "2h";
|
||||
expiresIn = "24h";
|
||||
}
|
||||
} else if (!expiresIn) {
|
||||
expiresIn = "2h";
|
||||
expiresIn = "24h";
|
||||
}
|
||||
|
||||
const payload: JWTPayload = { userId };
|
||||
@@ -250,9 +248,8 @@ class AuthManager {
|
||||
});
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
@@ -280,7 +277,7 @@ class AuthManager {
|
||||
|
||||
private parseExpiresIn(expiresIn: string): number {
|
||||
const match = expiresIn.match(/^(\d+)([smhd])$/);
|
||||
if (!match) return 2 * 60 * 60 * 1000;
|
||||
if (!match) return 24 * 60 * 60 * 1000;
|
||||
|
||||
const value = parseInt(match[1]);
|
||||
const unit = match[2];
|
||||
@@ -295,7 +292,7 @@ class AuthManager {
|
||||
case "d":
|
||||
return value * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return 2 * 60 * 60 * 1000;
|
||||
return 24 * 60 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,9 +361,8 @@ class AuthManager {
|
||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
@@ -423,9 +419,8 @@ class AuthManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
@@ -466,9 +461,8 @@ class AuthManager {
|
||||
.where(sql`${sessions.expiresAt} < datetime('now')`);
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
@@ -531,7 +525,7 @@ class AuthManager {
|
||||
|
||||
getSecureCookieOptions(
|
||||
req: RequestWithHeaders,
|
||||
maxAge: number = 2 * 60 * 60 * 1000,
|
||||
maxAge: number = 24 * 60 * 60 * 1000,
|
||||
) {
|
||||
return {
|
||||
httpOnly: false,
|
||||
@@ -613,9 +607,8 @@ class AuthManager {
|
||||
.where(eq(sessions.id, payload.sessionId))
|
||||
.then(async () => {
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
const remainingSessions = await db
|
||||
@@ -759,9 +752,8 @@ class AuthManager {
|
||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
@@ -827,9 +819,6 @@ class AuthManager {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device is trusted for TOTP bypass
|
||||
*/
|
||||
async isTrustedDevice(
|
||||
userId: string,
|
||||
deviceFingerprint: string,
|
||||
@@ -875,9 +864,6 @@ class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add device to trusted list for TOTP bypass
|
||||
*/
|
||||
async addTrustedDevice(
|
||||
userId: string,
|
||||
deviceFingerprint: string,
|
||||
@@ -925,9 +911,6 @@ class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove trusted device
|
||||
*/
|
||||
async removeTrustedDevice(
|
||||
userId: string,
|
||||
deviceFingerprint: string,
|
||||
|
||||
@@ -19,7 +19,7 @@ export function getRequestOrigin(req: Request | IncomingMessage): string {
|
||||
}
|
||||
|
||||
const portHeader = req.headers["x-forwarded-port"];
|
||||
const port =
|
||||
let port: string | undefined =
|
||||
typeof portHeader === "string"
|
||||
? portHeader.split(",")[0].trim()
|
||||
: undefined;
|
||||
@@ -31,8 +31,15 @@ export function getRequestOrigin(req: Request | IncomingMessage): string {
|
||||
? hostHeaderRaw.split(",")[0].trim()
|
||||
: String(hostHeaderRaw);
|
||||
|
||||
if (!port && hostHeader.includes(":")) {
|
||||
const parts = hostHeader.split(":");
|
||||
if (parts.length === 2 && !parts[0].includes("[")) {
|
||||
port = parts[1];
|
||||
}
|
||||
}
|
||||
|
||||
const hostWithoutPort = hostHeader.split(":")[0];
|
||||
if (port) {
|
||||
const hostWithoutPort = hostHeader.split(":")[0];
|
||||
const isDefaultPort =
|
||||
(protocol === "http" && port === "80") ||
|
||||
(protocol === "https" && port === "443");
|
||||
@@ -42,7 +49,7 @@ export function getRequestOrigin(req: Request | IncomingMessage): string {
|
||||
: `${protocol}://${hostWithoutPort}:${port}`;
|
||||
}
|
||||
|
||||
return `${protocol}://${hostHeader}`;
|
||||
return `${protocol}://${hostWithoutPort}`;
|
||||
}
|
||||
|
||||
export function getRequestOriginWithForceHTTPS(
|
||||
|
||||
@@ -810,6 +810,7 @@
|
||||
"globalSettingsSaved": "Global monitoring settings saved",
|
||||
"failedToSaveGlobalSettings": "Failed to save global monitoring settings",
|
||||
"failedToLoadGlobalSettings": "Failed to load global monitoring settings",
|
||||
"clampedToValidRange": "was adjusted to valid range",
|
||||
"sessionManagement": "Session Management",
|
||||
"loadingSessions": "Loading sessions...",
|
||||
"noActiveSessions": "No active sessions found.",
|
||||
|
||||
@@ -88,8 +88,12 @@ export function GeneralSettingsTab({
|
||||
metricsInterval: newMetrics,
|
||||
});
|
||||
toast.success(t("admin.globalSettingsSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.failedToSaveGlobalSettings"));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("admin.failedToSaveGlobalSettings");
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setMonitoringLoading(false);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,14 @@ export function Auth({
|
||||
const [localUsername, setLocalUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [signupConfirmPassword, setSignupConfirmPassword] = useState("");
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem("rememberMe");
|
||||
return saved === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [oidcLoading, setOidcLoading] = useState(false);
|
||||
const [internalLoggedIn, setInternalLoggedIn] = useState(false);
|
||||
@@ -175,6 +182,14 @@ export function Auth({
|
||||
}
|
||||
}, [totpRequired]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem("rememberMe", rememberMe.toString());
|
||||
} catch {
|
||||
// expected - localStorage might not be available
|
||||
}
|
||||
}, [rememberMe]);
|
||||
|
||||
useEffect(() => {
|
||||
getRegistrationAllowed().then((res) => {
|
||||
setRegistrationAllowed(res.allowed);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cn } from "@/lib/utils.ts";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Checkbox } from "@/components/ui/checkbox.tsx";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LanguageSwitcher } from "@/ui/desktop/user/LanguageSwitcher.tsx";
|
||||
@@ -93,6 +94,14 @@ export function Auth({
|
||||
const [localUsername, setLocalUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [signupConfirmPassword, setSignupConfirmPassword] = useState("");
|
||||
const [rememberMe, setRememberMe] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem("rememberMe");
|
||||
return saved === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [oidcLoading, setOidcLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -130,6 +139,14 @@ export function Auth({
|
||||
}
|
||||
}, [totpRequired]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem("rememberMe", rememberMe.toString());
|
||||
} catch {
|
||||
// expected - localStorage might not be available
|
||||
}
|
||||
}, [rememberMe]);
|
||||
|
||||
useEffect(() => {
|
||||
getRegistrationAllowed().then((res) => {
|
||||
setRegistrationAllowed(res.allowed);
|
||||
@@ -218,7 +235,7 @@ export function Auth({
|
||||
try {
|
||||
let res;
|
||||
if (tab === "login") {
|
||||
res = await loginUser(localUsername, password);
|
||||
res = await loginUser(localUsername, password, rememberMe);
|
||||
} else {
|
||||
if (password !== signupConfirmPassword) {
|
||||
toast.error(t("errors.passwordMismatch"));
|
||||
@@ -232,7 +249,7 @@ export function Auth({
|
||||
}
|
||||
|
||||
await registerUser(localUsername, password);
|
||||
res = await loginUser(localUsername, password);
|
||||
res = await loginUser(localUsername, password, rememberMe);
|
||||
}
|
||||
|
||||
if (res.requires_totp) {
|
||||
@@ -441,7 +458,7 @@ export function Auth({
|
||||
setTotpLoading(true);
|
||||
|
||||
try {
|
||||
const res = await verifyTOTPLogin(totpTempToken, totpCode);
|
||||
const res = await verifyTOTPLogin(totpTempToken, totpCode, rememberMe);
|
||||
|
||||
if (!res || !res.success) {
|
||||
throw new Error(t("errors.loginFailed"));
|
||||
@@ -1108,6 +1125,24 @@ export function Auth({
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
{tab === "login" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="rememberMe"
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(checked) =>
|
||||
setRememberMe(checked === true)
|
||||
}
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="rememberMe"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
{t("auth.rememberMe")}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
{tab === "signup" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="signup-confirm-password">
|
||||
|
||||
Reference in New Issue
Block a user