mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
fix: gate second factors after SSO/LDAP behind an admin setting
This commit is contained in:
1 parent
f217d8db89
commit
95abe24798
13 files changed
+307
-5
No files matched your search
@@ -36,6 +36,21 @@ export function isPasswordLoginSettingOn(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Off by default, matching 2.8: second factors only ran after password and
|
||||
* other local form logins. On, they also run after external methods (SSO,
|
||||
* LDAP), which needs the pending-cookie/temp_token step those methods'
|
||||
* clients (Termix-Mobile included) may not handle yet.
|
||||
*/
|
||||
export function isSecondFactorAfterExternalLoginEnabled(): boolean {
|
||||
try {
|
||||
const value = getCurrentSettingValue("second_factor_after_external_login");
|
||||
return value === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether anyone could still sign in without a password: trusted proxy auth,
|
||||
* or a login method reporting an enabled instance (an SSO provider).
|
||||
|
||||
@@ -317,6 +317,7 @@ const oidcLoginMethod: LoginMethod = {
|
||||
labelKey: "auth.loginWithSso",
|
||||
icon: "key-round",
|
||||
kind: "redirect",
|
||||
external: true,
|
||||
describe: async () =>
|
||||
(await listPublicProviders(["oidc", "github", "google"])).map(
|
||||
(provider) => ({
|
||||
@@ -345,6 +346,7 @@ const ldapLoginMethod: LoginMethod = {
|
||||
labelKey: "auth.loginWithLdap",
|
||||
icon: "server",
|
||||
kind: "form",
|
||||
external: true,
|
||||
describe: async () =>
|
||||
(await listPublicProviders(["ldap"])).map((provider) => ({
|
||||
id: String(provider.id),
|
||||
|
||||
@@ -25,9 +25,13 @@ import {
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type { UserRecord } from "../database/repositories/user-repository.js";
|
||||
import { ensureCoreLoginProviders } from "./core-auth.js";
|
||||
import {
|
||||
ensureCoreLoginProviders,
|
||||
isSecondFactorAfterExternalLoginEnabled,
|
||||
} from "./core-auth.js";
|
||||
import { findOrProvisionExternalUser } from "./provisioning.js";
|
||||
import {
|
||||
getLoginMethod,
|
||||
getSecondFactor,
|
||||
listSecondFactors,
|
||||
type SecondFactor,
|
||||
@@ -112,6 +116,18 @@ export async function evaluateSecondFactors(
|
||||
return { required: [...required.values()], blocked };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether second factors should even be asked for this login. Off, they only
|
||||
* run for password and other local form logins, matching 2.8: the setting
|
||||
* governs external methods (SSO, LDAP) alone.
|
||||
*/
|
||||
function shouldRunSecondFactors(methodId: string): boolean {
|
||||
ensureCoreLoginProviders();
|
||||
const method = getLoginMethod(methodId);
|
||||
if (!method?.external) return true;
|
||||
return isSecondFactorAfterExternalLoginEnabled();
|
||||
}
|
||||
|
||||
async function isTrustedDevice(req: Request, userId: string): Promise<boolean> {
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
const fingerprint = generateDeviceFingerprint(deviceInfo, getDeviceId(req));
|
||||
@@ -221,7 +237,9 @@ export async function runLogin(
|
||||
`${context.methodId}_role_shared_credentials`,
|
||||
);
|
||||
|
||||
const factors = await evaluateSecondFactors(user.id);
|
||||
const factors = shouldRunSecondFactors(context.methodId)
|
||||
? await evaluateSecondFactors(user.id)
|
||||
: { required: [], blocked: [] };
|
||||
if (factors.blocked.length > 0) {
|
||||
authLogger.warn("Login refused: an enrolled second factor is unavailable", {
|
||||
operation: "second_factor_unavailable",
|
||||
|
||||
@@ -1378,6 +1378,18 @@ router.patch("/registration-allowed", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-auto-provision:
|
||||
* get:
|
||||
* summary: Get OIDC auto-provision status
|
||||
* description: Whether a new user account is created automatically on first SSO sign-in.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OIDC auto-provision status.
|
||||
*/
|
||||
router.get("/oidc-auto-provision", async (_req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
@@ -1394,6 +1406,33 @@ router.get("/oidc-auto-provision", async (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-auto-provision:
|
||||
* patch:
|
||||
* summary: Set OIDC auto-provision status
|
||||
* description: Enables or disables automatic account creation on first SSO sign-in.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OIDC auto-provision status updated.
|
||||
* 400:
|
||||
* description: Invalid value for enabled.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to set OIDC auto-provision setting.
|
||||
*/
|
||||
router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
@@ -1416,6 +1455,95 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/second-factor-after-external-login:
|
||||
* get:
|
||||
* summary: Get the external-login second-factor setting
|
||||
* description: Whether an enrolled second factor is asked for after an external login method (SSO, LDAP), in addition to password and other local logins.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: External-login second-factor setting.
|
||||
*/
|
||||
router.get("/second-factor-after-external-login", async (_req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||
"second_factor_after_external_login",
|
||||
false,
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error(
|
||||
"Failed to get second-factor-after-external-login setting",
|
||||
err,
|
||||
);
|
||||
res.status(500).json({
|
||||
error: "Failed to get second-factor-after-external-login setting",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/second-factor-after-external-login:
|
||||
* patch:
|
||||
* summary: Set the external-login second-factor setting
|
||||
* description: Enables or disables asking for an enrolled second factor after an external login method (SSO, LDAP).
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: External-login second-factor setting updated.
|
||||
* 400:
|
||||
* description: Invalid value for enabled.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to set second-factor-after-external-login setting.
|
||||
*/
|
||||
router.patch(
|
||||
"/second-factor-after-external-login",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const user = await requireCurrentAdmin(userId);
|
||||
if (!user) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== "boolean") {
|
||||
return res.status(400).json({ error: "Invalid value for enabled" });
|
||||
}
|
||||
await createCurrentSettingsRepository().set(
|
||||
"second_factor_after_external_login",
|
||||
enabled ? "true" : "false",
|
||||
);
|
||||
res.json({ enabled });
|
||||
} catch (err) {
|
||||
authLogger.error(
|
||||
"Failed to set second-factor-after-external-login setting",
|
||||
err,
|
||||
);
|
||||
res.status(500).json({
|
||||
error: "Failed to set second-factor-after-external-login setting",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-silent-login-default:
|
||||
|
||||
@@ -358,7 +358,7 @@ describe("LDAP through the pipeline", () => {
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it("runs TOTP for an LDAP user who enrolled", async () => {
|
||||
it("skips TOTP for an LDAP user who enrolled when the external-login setting is off", async () => {
|
||||
h.providerConfig = providerConfig;
|
||||
h.state.users.set("u-bob", {
|
||||
id: "u-bob",
|
||||
@@ -378,6 +378,30 @@ describe("LDAP through the pipeline", () => {
|
||||
methodId: "ldap",
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(result.kind).toBe("session");
|
||||
});
|
||||
|
||||
it("runs TOTP for an LDAP user who enrolled when the external-login setting is on", async () => {
|
||||
h.providerConfig = providerConfig;
|
||||
h.state.settings.set("second_factor_after_external_login", "true");
|
||||
h.state.users.set("u-bob", {
|
||||
id: "u-bob",
|
||||
username: "Bob Builder",
|
||||
passwordHash: "",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
oidcIdentifier: "ldap:4:bob",
|
||||
totpEnabled: true,
|
||||
totpSecret: "JBSWY3DPEHPK3PXP",
|
||||
});
|
||||
const identity = await verifyLdapLogin({
|
||||
body: { providerId: 4, username: "bob", password: "hunter2" },
|
||||
ip: "10.0.0.3",
|
||||
});
|
||||
const result = await runLogin(fakeRequest() as never, identity, {
|
||||
methodId: "ldap",
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(result.kind).toBe("second-factor");
|
||||
});
|
||||
});
|
||||
@@ -318,6 +318,13 @@ describe("second factors", () => {
|
||||
expect(h.state.users.get("u1")!.totpBackupCodes).not.toContain("BACKUP01");
|
||||
});
|
||||
|
||||
it("still stops password login for TOTP when the external-login setting is off", async () => {
|
||||
enrolTotp();
|
||||
h.state.settings.set("second_factor_after_external_login", "false");
|
||||
const res = await passwordLogin({ username: "alice", password: PASSWORD });
|
||||
expect(res.body).toMatchObject({ requires_totp: true });
|
||||
});
|
||||
|
||||
it("rejects a wrong code without a session", async () => {
|
||||
enrolTotp();
|
||||
const first = await passwordLogin({
|
||||
@@ -549,7 +556,31 @@ describe("external identities", () => {
|
||||
expect(h.state.users.get("u9")!.isAdmin).toBe(false);
|
||||
});
|
||||
|
||||
it("runs enrolled second factors for SSO users too", async () => {
|
||||
it("skips enrolled second factors for SSO users when the setting is off", async () => {
|
||||
addUser({
|
||||
id: "u9",
|
||||
username: "Alice",
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
totpEnabled: true,
|
||||
totpSecret,
|
||||
});
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
userId: "u9",
|
||||
providerId: "3",
|
||||
subject: "sub-1",
|
||||
email: null,
|
||||
});
|
||||
const result = await runLogin(fakeRequest() as never, external, {
|
||||
methodId: "oidc",
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(result.kind).toBe("session");
|
||||
});
|
||||
|
||||
it("runs enrolled second factors for SSO users when the setting is on", async () => {
|
||||
h.state.settings.set("second_factor_after_external_login", "true");
|
||||
addUser({
|
||||
id: "u9",
|
||||
username: "Alice",
|
||||
@@ -621,6 +652,7 @@ describe("redirect logins", () => {
|
||||
});
|
||||
|
||||
it("sends a second-factor step back with a pending cookie", async () => {
|
||||
h.state.settings.set("second_factor_after_external_login", "true");
|
||||
addUser({
|
||||
id: "u9",
|
||||
username: "Alice",
|
||||
|
||||
Reference in new issue
Block a user