mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 08:05:23 +00:00
feat: harden plugin api for separate repos, signed updates and generic external login
This commit is contained in:
1 parent
fb9c168b6b
commit
80944301dd
310 files changed
+6577
-4927
No files matched your search
@@ -0,0 +1,285 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
createCurrentUserRepository,
|
||||
createCurrentUserAuthRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
||||
import { isExternalAccount } from "../../auth/external-account.js";
|
||||
|
||||
type UserExternalAccountRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
authManager: AuthManager;
|
||||
};
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merging an account that only signs in externally (SSO, LDAP or any login
|
||||
* plugin) into a password account, and taking the external sign-in away
|
||||
* again. The identities themselves live in user_external_identities.
|
||||
*/
|
||||
export function registerUserExternalAccountRoutes(
|
||||
router: Router,
|
||||
{ authenticateJWT, authManager }: UserExternalAccountRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/link-external-to-password:
|
||||
* post:
|
||||
* summary: Merge an external account into a password account
|
||||
* description: Moves an external-only account's sign-in identities onto a password account and deletes the external-only account (admin only). The 2.8 path /users/link-oidc-to-password and its oidcUserId field are accepted too.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* externalUserId:
|
||||
* type: string
|
||||
* targetUsername:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Accounts linked successfully.
|
||||
* 400:
|
||||
* description: Invalid request or incompatible accounts.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to link accounts.
|
||||
*/
|
||||
const link = async (req: Request, res: Response) => {
|
||||
const adminUserId = (req as AuthenticatedRequest).userId;
|
||||
const externalUserId = req.body?.externalUserId ?? req.body?.oidcUserId;
|
||||
const targetUsername = req.body?.targetUsername;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(externalUserId) ||
|
||||
!isNonEmptyString(targetUsername)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "External user ID and target username are required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const identities = createCurrentUserAuthRepository();
|
||||
const adminUser = await userRepository.findById(adminUserId);
|
||||
if (!adminUser?.isAdmin) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
const externalUser = await userRepository.findById(externalUserId);
|
||||
if (!externalUser) {
|
||||
return res.status(404).json({ error: "External user not found" });
|
||||
}
|
||||
if (!isExternalAccount(externalUser)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Source user does not sign in externally" });
|
||||
}
|
||||
|
||||
const targetUser = await userRepository.findByUsername(targetUsername);
|
||||
if (!targetUser) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Target password user not found" });
|
||||
}
|
||||
if (isExternalAccount(targetUser) || !targetUser.passwordHash) {
|
||||
return res.status(400).json({
|
||||
error: "Target user must be a password-based account",
|
||||
});
|
||||
}
|
||||
if ((await identities.listIdentitiesForUser(targetUser.id)).length > 0) {
|
||||
return res.status(400).json({
|
||||
error: "Target user already has an external sign-in",
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.info("Linking an external account to a password account", {
|
||||
operation: "link_external_to_password",
|
||||
externalUserId,
|
||||
targetUserId: targetUser.id,
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
await userRepository.update(targetUser.id, {
|
||||
isOidc: true,
|
||||
oidcIdentifier: externalUser.oidcIdentifier,
|
||||
});
|
||||
await identities.moveIdentities(externalUserId, targetUser.id);
|
||||
|
||||
await authManager.revokeAllUserSessions(externalUserId);
|
||||
authManager.logoutUser(externalUserId);
|
||||
await deleteUserAndRelatedData(externalUserId);
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("link_external_explicit_save");
|
||||
} catch (saveError) {
|
||||
authLogger.error(
|
||||
"Failed to persist account linking to disk",
|
||||
saveError,
|
||||
{
|
||||
operation: "link_external_save_failed",
|
||||
externalUserId,
|
||||
targetUserId: targetUser.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
authLogger.success(
|
||||
`External account ${externalUser.username} linked to password account ${targetUser.username}`,
|
||||
{
|
||||
operation: "link_external_to_password_success",
|
||||
externalUserId,
|
||||
targetUserId: targetUser.id,
|
||||
adminUserId,
|
||||
},
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${externalUser.username} has been linked to ${targetUser.username}. The password account can now sign in both ways.`,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to link an external account", err, {
|
||||
operation: "link_external_to_password_failed",
|
||||
externalUserId,
|
||||
targetUsername,
|
||||
adminUserId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to link accounts",
|
||||
details: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/unlink-external-from-password:
|
||||
* post:
|
||||
* summary: Remove external sign-in from a password account
|
||||
* description: Removes every external sign-in identity from an account that also has a password (admin only). The 2.8 path /users/unlink-oidc-from-password is accepted too.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* userId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: External sign-in removed.
|
||||
* 400:
|
||||
* description: Invalid request, or the user has no external sign-in or no password.
|
||||
* 403:
|
||||
* description: Admin privileges required.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to unlink.
|
||||
*/
|
||||
const unlink = async (req: Request, res: Response) => {
|
||||
const adminUserId = (req as AuthenticatedRequest).userId;
|
||||
const { userId } = req.body ?? {};
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: "User ID is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const adminUser = await userRepository.findById(adminUserId);
|
||||
if (!adminUser?.isAdmin) {
|
||||
authLogger.warn("Non-admin attempted to remove an external sign-in", {
|
||||
operation: "unlink_external_unauthorized",
|
||||
adminUserId,
|
||||
targetUserId: userId,
|
||||
});
|
||||
return res.status(403).json({ error: "Admin privileges required" });
|
||||
}
|
||||
|
||||
const targetUser = await userRepository.findById(userId);
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
if (!isExternalAccount(targetUser)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "User does not have an external sign-in" });
|
||||
}
|
||||
if (!targetUser.passwordHash) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Cannot remove the external sign-in from a user without a password. This would leave the user unable to sign in.",
|
||||
});
|
||||
}
|
||||
|
||||
await userRepository.update(targetUser.id, {
|
||||
isOidc: false,
|
||||
oidcIdentifier: null,
|
||||
});
|
||||
await createCurrentUserAuthRepository().unlinkIdentitiesForUser(
|
||||
targetUser.id,
|
||||
);
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("unlink_external_explicit_save");
|
||||
} catch (saveError) {
|
||||
authLogger.error(
|
||||
"Failed to save database after removing an external sign-in",
|
||||
saveError,
|
||||
{
|
||||
operation: "unlink_external_save_failed",
|
||||
targetUserId: targetUser.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
authLogger.success("External sign-in removed from password account", {
|
||||
operation: "unlink_external_from_password_success",
|
||||
targetUserId: targetUser.id,
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `External sign-in has been removed from ${targetUser.username}. The user can now only sign in with a password.`,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to remove an external sign-in", err, {
|
||||
operation: "unlink_external_from_password_failed",
|
||||
targetUserId: userId,
|
||||
adminUserId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to unlink",
|
||||
details: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
router.post("/link-external-to-password", authenticateJWT, link);
|
||||
router.post("/unlink-external-from-password", authenticateJWT, unlink);
|
||||
// 2.8 paths, kept until 3.0.0.
|
||||
router.post("/link-oidc-to-password", authenticateJWT, link);
|
||||
router.post("/unlink-oidc-from-password", authenticateJWT, unlink);
|
||||
}
|
||||
Reference in new issue
Block a user