mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
376 lines
11 KiB
TypeScript
376 lines
11 KiB
TypeScript
/**
|
|
* Finds or creates the user behind an external sign-in (OIDC, LDAP, a
|
|
* plugin's provider). The rules used to be copied into every SSO route; they
|
|
* live here once so every login method gets the same ones:
|
|
*
|
|
* - the allowed-users list is checked on every sign-in, new user or not
|
|
* - the first user ever becomes admin and skips the list
|
|
* - anyone else is only created when SSO auto-provisioning is on
|
|
* - an admin group, when the provider reports one, keeps admin in step
|
|
* - a mapped role set, when the provider reports one, keeps roles in step
|
|
*/
|
|
|
|
import { nanoid } from "nanoid";
|
|
import { authLogger } from "../utils/logger.js";
|
|
import { AuthManager } from "../utils/auth-manager.js";
|
|
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
|
|
import { PermissionManager } from "../utils/permission-manager.js";
|
|
import {
|
|
createCurrentRoleRepository,
|
|
createCurrentSettingsRepository,
|
|
createCurrentUserAuthRepository,
|
|
createCurrentUserRepository,
|
|
} from "../database/repositories/factory.js";
|
|
import type { UserRecord } from "../database/repositories/user-repository.js";
|
|
import { isExternalUserAllowed } from "./allowed-users.js";
|
|
import { LoginMethodError, type VerifiedIdentity } from "./types.js";
|
|
|
|
type ExternalIdentity = Extract<VerifiedIdentity, { kind: "external" }>;
|
|
|
|
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
/** SSO auto-provisioning: the admin setting, or OIDC_ALLOW_REGISTRATION. */
|
|
async function isExternalProvisioningAllowed(): Promise<boolean> {
|
|
try {
|
|
if (
|
|
await createCurrentSettingsRepository().getBoolean(
|
|
"oidc_auto_provision",
|
|
false,
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
} catch {
|
|
// fall through to the environment
|
|
}
|
|
return (
|
|
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() === "true"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether a newly provisioned user must wait for an admin. There is no
|
|
* approval queue yet, so nobody does; a plugin or a later release can change
|
|
* that here without touching any login method.
|
|
*/
|
|
async function isApprovalRequired(
|
|
_identity: ExternalIdentity,
|
|
): Promise<boolean> {
|
|
return false;
|
|
}
|
|
|
|
function isAllowed(identity: ExternalIdentity): boolean {
|
|
if (!identity.allowedUsers) return true;
|
|
const email = identity.email ?? undefined;
|
|
return (
|
|
isExternalUserAllowed(identity.allowedUsers, identity.subject, email) ||
|
|
(!!identity.legacyIdentifier &&
|
|
isExternalUserAllowed(
|
|
identity.allowedUsers,
|
|
identity.legacyIdentifier,
|
|
email,
|
|
))
|
|
);
|
|
}
|
|
|
|
async function syncAdmin(
|
|
user: UserRecord,
|
|
isAdmin: boolean,
|
|
): Promise<UserRecord> {
|
|
if (!!user.isAdmin === isAdmin) return user;
|
|
const updated =
|
|
(await createCurrentUserRepository().update(user.id, { isAdmin })) ?? user;
|
|
try {
|
|
await createCurrentRoleRepository().switchUserRoleName({
|
|
userId: user.id,
|
|
addRoleName: isAdmin ? "admin" : "user",
|
|
removeRoleName: isAdmin ? "user" : "admin",
|
|
grantedBy: user.id,
|
|
});
|
|
} catch {
|
|
// Roles follow on the next sign-in; the admin flag itself is saved.
|
|
}
|
|
authLogger.info("Admin status synced from the identity provider", {
|
|
operation: "external_admin_sync",
|
|
userId: user.id,
|
|
isAdmin,
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Adds and removes the roles a provider's group map manages. Roles assigned
|
|
* by hand, and roles outside the map, are never touched.
|
|
*/
|
|
async function applyRoleSync(
|
|
userId: string,
|
|
roleSync: { desired: string[]; managed: string[] },
|
|
): Promise<void> {
|
|
const desired = new Set(roleSync.desired);
|
|
const managed = new Set(roleSync.managed);
|
|
const roleRepository = createCurrentRoleRepository();
|
|
const currentRoles = await roleRepository.listUserRoles(userId);
|
|
const currentNames = new Set(currentRoles.map((role) => role.roleName));
|
|
|
|
const toAdd = [...desired].filter((name) => !currentNames.has(name));
|
|
const toRemove = currentRoles.filter(
|
|
(role) => managed.has(role.roleName) && !desired.has(role.roleName),
|
|
);
|
|
|
|
for (const roleName of toAdd) {
|
|
const assigned = await roleRepository.assignRoleNameToUser({
|
|
userId,
|
|
roleName,
|
|
grantedBy: userId,
|
|
});
|
|
if (!assigned) {
|
|
authLogger.warn("Role map references a role that does not exist", {
|
|
operation: "external_role_map_missing_role",
|
|
userId,
|
|
roleName,
|
|
});
|
|
}
|
|
}
|
|
for (const role of toRemove) {
|
|
await roleRepository.removeRoleFromUser(userId, role.roleId);
|
|
}
|
|
|
|
if (toAdd.length > 0 || toRemove.length > 0) {
|
|
authLogger.info("Roles synced from provider group membership", {
|
|
operation: "external_role_map_sync",
|
|
userId,
|
|
added: toAdd,
|
|
removed: toRemove.map((role) => role.roleName),
|
|
});
|
|
PermissionManager.getInstance().invalidateUserPermissionCache(userId);
|
|
}
|
|
}
|
|
|
|
async function findLinkedUser(
|
|
identity: ExternalIdentity,
|
|
): Promise<UserRecord | null> {
|
|
const identities = createCurrentUserAuthRepository();
|
|
const users = createCurrentUserRepository();
|
|
|
|
const linked = await identities.findIdentity(
|
|
identity.provider,
|
|
identity.subject,
|
|
);
|
|
if (linked) {
|
|
const user = await users.findById(linked.userId);
|
|
if (user) return user;
|
|
}
|
|
|
|
// Accounts from before user_external_identities matched on the raw
|
|
// identifier column; link them the first time they sign in. The column is
|
|
// not scoped to a provider, so only an SSO account that has no link yet,
|
|
// from the same provider when both say which, can be claimed this way.
|
|
if (identity.legacyIdentifier) {
|
|
const legacy = await users.findByExternalIdentifier(
|
|
identity.legacyIdentifier,
|
|
);
|
|
if (legacy && (await mayClaimLegacyAccount(legacy, identity))) {
|
|
await identities.linkIdentity({
|
|
userId: legacy.id,
|
|
providerId: identity.provider,
|
|
subject: identity.subject,
|
|
email: identity.email,
|
|
});
|
|
return legacy;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function mayClaimLegacyAccount(
|
|
legacy: UserRecord,
|
|
identity: ExternalIdentity,
|
|
): Promise<boolean> {
|
|
if (!legacy.isOidc) return false;
|
|
if (
|
|
legacy.ssoProviderId != null &&
|
|
identity.ssoProviderId != null &&
|
|
legacy.ssoProviderId !== identity.ssoProviderId
|
|
) {
|
|
return false;
|
|
}
|
|
const links = await createCurrentUserAuthRepository().listIdentitiesForUser(
|
|
legacy.id,
|
|
);
|
|
return links.length === 0;
|
|
}
|
|
|
|
/**
|
|
* A username nobody else has. An identity provider's display name must never
|
|
* give an SSO account the same username as a local one, such as "admin".
|
|
*/
|
|
async function freeUsername(
|
|
wanted: string,
|
|
selfId: string | null,
|
|
): Promise<string> {
|
|
const users = createCurrentUserRepository();
|
|
let candidate = wanted;
|
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
const holder = await users.findByUsername(candidate);
|
|
if (!holder || holder.id === selfId) return candidate;
|
|
candidate = `${wanted}-${nanoid(4)}`;
|
|
}
|
|
return `${wanted}-${nanoid(10)}`;
|
|
}
|
|
|
|
async function createUser(
|
|
identity: ExternalIdentity,
|
|
deviceType: string,
|
|
): Promise<UserRecord> {
|
|
const users = createCurrentUserRepository();
|
|
const id = nanoid();
|
|
const created = await users.createFirstSsoUser({
|
|
id,
|
|
username: await freeUsername(identity.name || identity.subject, null),
|
|
passwordHash: "",
|
|
isAdmin: !!identity.isAdmin,
|
|
isOidc: true,
|
|
oidcIdentifier:
|
|
identity.legacyIdentifier ?? `${identity.provider}:${identity.subject}`,
|
|
ssoProviderId: identity.ssoProviderId ?? null,
|
|
});
|
|
|
|
try {
|
|
const assigned = await createCurrentRoleRepository().assignRoleNameToUser({
|
|
userId: id,
|
|
roleName: created.user.isAdmin ? "admin" : "user",
|
|
grantedBy: id,
|
|
});
|
|
if (!assigned) {
|
|
authLogger.warn("Default role not found for a provisioned user", {
|
|
operation: "external_default_role",
|
|
userId: id,
|
|
});
|
|
}
|
|
} catch (roleError) {
|
|
authLogger.error("Failed to assign a default role", roleError, {
|
|
operation: "external_default_role",
|
|
userId: id,
|
|
});
|
|
}
|
|
|
|
try {
|
|
const sessionDurationMs =
|
|
deviceType === "desktop" || deviceType === "mobile"
|
|
? THIRTY_DAYS_MS
|
|
: ONE_DAY_MS;
|
|
await AuthManager.getInstance().registerExternalUser(id, sessionDurationMs);
|
|
} catch (encryptionError) {
|
|
await users.delete(id);
|
|
authLogger.error(
|
|
"Failed to set up a provisioned user's encryption, user removed",
|
|
encryptionError,
|
|
{ operation: "external_user_create_failed", userId: id },
|
|
);
|
|
throw new LoginMethodError(
|
|
"Failed to setup user security - user creation cancelled",
|
|
500,
|
|
"setup_failed",
|
|
);
|
|
}
|
|
|
|
await createCurrentUserAuthRepository().linkIdentity({
|
|
userId: id,
|
|
providerId: identity.provider,
|
|
subject: identity.subject,
|
|
email: identity.email,
|
|
});
|
|
|
|
try {
|
|
await DatabaseSaveTrigger.forceSave("external_user_create");
|
|
} catch (saveError) {
|
|
authLogger.error("Failed to persist a provisioned user", saveError, {
|
|
operation: "external_user_create_save_failed",
|
|
userId: id,
|
|
});
|
|
}
|
|
|
|
return created.user;
|
|
}
|
|
|
|
export async function findOrProvisionExternalUser(
|
|
identity: ExternalIdentity,
|
|
deviceType: string,
|
|
): Promise<UserRecord> {
|
|
let user = await findLinkedUser(identity);
|
|
|
|
if (!user) {
|
|
const isFirstUser = (await createCurrentUserRepository().countAll()) === 0;
|
|
if (!isFirstUser) {
|
|
if (!isAllowed(identity)) {
|
|
authLogger.warn("External user not in the allowed list", {
|
|
operation: "external_user_not_allowed",
|
|
provider: identity.provider,
|
|
});
|
|
throw new LoginMethodError("User not allowed", 403, "user_not_allowed");
|
|
}
|
|
if (!(await isExternalProvisioningAllowed())) {
|
|
authLogger.warn("External sign-in refused, provisioning is off", {
|
|
operation: "external_registration_disabled",
|
|
provider: identity.provider,
|
|
});
|
|
throw new LoginMethodError(
|
|
"Registration is disabled",
|
|
403,
|
|
"registration_disabled",
|
|
);
|
|
}
|
|
if (await isApprovalRequired(identity)) {
|
|
throw new LoginMethodError(
|
|
"Your account is waiting for an admin to approve it",
|
|
403,
|
|
"approval_required",
|
|
);
|
|
}
|
|
}
|
|
user = await createUser(identity, deviceType);
|
|
} else {
|
|
if (!isAllowed(identity)) {
|
|
authLogger.warn("External user not in the allowed list", {
|
|
operation: "external_user_not_allowed_existing",
|
|
provider: identity.provider,
|
|
userId: user.id,
|
|
});
|
|
throw new LoginMethodError("User not allowed", 403, "user_not_allowed");
|
|
}
|
|
|
|
// A user who also has a password picked their own username; leave it.
|
|
const isDualAuth = !!user.passwordHash && user.passwordHash.trim() !== "";
|
|
if (!isDualAuth && identity.name && user.username !== identity.name) {
|
|
// Taken by someone else: keep the name the account already has.
|
|
const holder = await createCurrentUserRepository().findByUsername(
|
|
identity.name,
|
|
);
|
|
if (!holder || holder.id === user.id) {
|
|
user =
|
|
(await createCurrentUserRepository().update(user.id, {
|
|
username: identity.name,
|
|
})) ?? user;
|
|
}
|
|
}
|
|
|
|
if (identity.isAdmin !== undefined) {
|
|
user = await syncAdmin(user, identity.isAdmin);
|
|
}
|
|
}
|
|
|
|
if (identity.roleSync) {
|
|
try {
|
|
await applyRoleSync(user.id, identity.roleSync);
|
|
} catch (roleSyncError) {
|
|
authLogger.error("Failed to sync roles from provider", roleSyncError, {
|
|
operation: "external_role_map_sync_failed",
|
|
userId: user.id,
|
|
});
|
|
}
|
|
}
|
|
|
|
return user;
|
|
}
|