e0347f85f0
- Replace "Twake Drive" → "Workavia Drive" in static (index.html, manifest.json), all 16 frontend locales, and shared.tsx. - Patch console/clients/remote.ts (OIDC remote) and console/web/controller.ts (local signup) to derive a per-tenant Company from the user's email domain via uuidv5, replacing the upstream all-zero hardcode.
409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
import { ConsoleServiceClient } from "../client-interface";
|
|
import {
|
|
ConsoleCompany,
|
|
ConsoleHookCompany,
|
|
ConsoleHookUser,
|
|
ConsoleOptions,
|
|
CreateConsoleCompany,
|
|
CreateConsoleUser,
|
|
CreatedConsoleCompany,
|
|
CreatedConsoleUser,
|
|
UpdateConsoleUserRole,
|
|
UpdatedConsoleUserRole,
|
|
} from "../types";
|
|
|
|
import { OidcJwtVerifier } from "./remote-jwks-verifier";
|
|
import { CrudException } from "../../../core/platform/framework/api/crud-service";
|
|
import { logger } from "../../../core/platform/framework/logger";
|
|
import gr from "../../global-resolver";
|
|
import Company, { CompanySearchKey } from "../../user/entities/company";
|
|
import User, { getInstance } from "../../user/entities/user";
|
|
import { getInstance as getCompanyInstance } from "../../user/entities/company";
|
|
import { ConsoleServiceImpl } from "../service";
|
|
import coalesce from "../../../utils/coalesce";
|
|
import config from "config";
|
|
import { CompanyUserRole } from "src/services/user/web/types";
|
|
import { v5 as uuidv5 } from "uuid";
|
|
|
|
// [Workavia] Multi-tenant company resolution.
|
|
//
|
|
// Upstream tdrive hardcodes every OIDC user into the all-zero Company
|
|
// UUID. That breaks Workavia's tenant isolation: users from different
|
|
// customer domains (e.g. meconsult.fr, acme.com) would all land in the
|
|
// same Company and could see each other's "Shared with me" results,
|
|
// search each other in the share-with-user picker, etc.
|
|
//
|
|
// We derive a deterministic Company UUID per email domain via uuidv5
|
|
// over a fixed namespace, and use the bare domain as the Company's
|
|
// human name. First user from a domain bootstraps the Company; every
|
|
// subsequent user from that domain joins it.
|
|
//
|
|
// Namespace UUID is stable & arbitrary — picked once and committed.
|
|
const WORKAVIA_NAMESPACE = "1b1c8c44-3a5a-4e2c-bfa1-d3e2f1a0c0ff";
|
|
|
|
function workaviaCompanyForEmail(email: string): { id: string; name: string } {
|
|
// Default fallback for malformed emails — keeps behaviour identical
|
|
// to upstream (one shared Company) for the broken case so we don't
|
|
// break the system on a typo.
|
|
const at = email.lastIndexOf("@");
|
|
const domain = at >= 0 ? email.slice(at + 1).toLowerCase() : "tdrive.local";
|
|
return { id: uuidv5(domain, WORKAVIA_NAMESPACE), name: domain };
|
|
}
|
|
import Session from "../entities/session";
|
|
export class ConsoleRemoteClient implements ConsoleServiceClient {
|
|
version: "1";
|
|
|
|
private infos: ConsoleOptions;
|
|
private verifier: OidcJwtVerifier;
|
|
|
|
private rootAdmins: string[] = config.has("drive.rootAdmins")
|
|
? config.get("drive.rootAdmins")
|
|
: [];
|
|
|
|
constructor(consoleInstance: ConsoleServiceImpl) {
|
|
this.infos = consoleInstance.consoleOptions;
|
|
this.verifier = new OidcJwtVerifier({
|
|
clientId: this.infos.client_id,
|
|
issuer: this.infos.issuer?.replace(/\/+$/, ""),
|
|
jwksUri: this.infos.jwks_uri,
|
|
// For local deployment create a https agent that ignore self signed certificate
|
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
requestAgent: new (require("https").Agent)({
|
|
rejectUnauthorized: this.infos.issuer.includes("example.com") ? false : true,
|
|
}),
|
|
});
|
|
}
|
|
fetchCompanyInfo(consoleCompanyCode: string): Promise<ConsoleHookCompany> {
|
|
throw new Error(`Method not implemented, ${consoleCompanyCode}.`);
|
|
}
|
|
resendVerificationEmail(email: string): Promise<void> {
|
|
throw new Error(`Method not implemented, ${email}.`);
|
|
}
|
|
|
|
private auth() {
|
|
return {};
|
|
}
|
|
|
|
async addUserToCompany(
|
|
company: ConsoleCompany,
|
|
user: CreateConsoleUser,
|
|
): Promise<CreatedConsoleUser> {
|
|
logger.info(`Method not implemented, ${company.id}, ${user.id}.`);
|
|
return null;
|
|
}
|
|
|
|
async updateUserRole(
|
|
company: ConsoleCompany,
|
|
user: UpdateConsoleUserRole,
|
|
): Promise<UpdatedConsoleUserRole> {
|
|
logger.info("Remote: updateUserRole");
|
|
logger.info(`Method not implemented, ${company.id}, ${user.id}.`);
|
|
return null;
|
|
}
|
|
|
|
async createCompany(company: CreateConsoleCompany): Promise<CreatedConsoleCompany> {
|
|
logger.info("Remote: createCompany");
|
|
logger.info(`Method not implemented, ${company}.`);
|
|
return null;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
addUserToTdrive(user: CreateConsoleUser): Promise<User> {
|
|
logger.info("Remote: addUserToTdrive");
|
|
//should do noting for real console
|
|
return Promise.resolve(undefined);
|
|
}
|
|
|
|
async updateLocalCompanyFromConsole(partialCompanyDTO: ConsoleHookCompany): Promise<Company> {
|
|
logger.info(`Method not implemented, ${partialCompanyDTO}.`);
|
|
return null;
|
|
}
|
|
|
|
async updateLocalUserFromConsole(userDTO: ConsoleHookUser): Promise<User> {
|
|
logger.info("Remote: updateLocalUserFromConsole");
|
|
|
|
if (!userDTO) {
|
|
throw CrudException.badRequest("User not found on Console");
|
|
}
|
|
|
|
if (userDTO.roles) {
|
|
const roles = userDTO.roles.filter(
|
|
role => role.applications === undefined || role.applications.find(a => a.code === "tdrive"),
|
|
);
|
|
//REMOVE LATER
|
|
logger.info(`Roles are: ${roles}.`);
|
|
}
|
|
|
|
let user = await gr.services.users.getByConsoleId(userDTO.email || userDTO._id);
|
|
|
|
if (!user) {
|
|
if (!userDTO.email) {
|
|
// if the id is an email, use it as email
|
|
if (userDTO._id.includes("@")) {
|
|
userDTO.email = userDTO._id;
|
|
} else {
|
|
throw CrudException.badRequest(`Email is required: ${JSON.stringify(userDTO)}`);
|
|
}
|
|
}
|
|
|
|
let username = userDTO.email
|
|
.split("@")[0]
|
|
.toLocaleLowerCase()
|
|
.replace(/[^a-zA-Z0-9]/g, "")
|
|
.replace(/ +/g, "_");
|
|
|
|
if (await gr.services.users.isEmailAlreadyInUse(userDTO.email)) {
|
|
throw CrudException.badRequest("Console user not created because email already exists");
|
|
}
|
|
|
|
username = await gr.services.users.getAvailableUsername(username);
|
|
if (!username) {
|
|
throw CrudException.badRequest("Console user not created because username already exists");
|
|
}
|
|
|
|
user = getInstance({});
|
|
user.username_canonical = (username || "").toLocaleLowerCase();
|
|
user.email_canonical = userDTO.email;
|
|
user.deleted = false;
|
|
}
|
|
|
|
user.email_canonical = coalesce(userDTO.email, user.email_canonical);
|
|
user.phone = "";
|
|
user.first_name = coalesce(userDTO.name, user.first_name);
|
|
user.last_name = coalesce(userDTO.surname, user.last_name);
|
|
user.identity_provider = "console";
|
|
user.identity_provider_id = userDTO.email;
|
|
user.mail_verified = coalesce(userDTO.isVerified, user.mail_verified);
|
|
if (userDTO.preference) {
|
|
user.preferences = user.preferences || {};
|
|
user.preferences.allow_tracking = coalesce(
|
|
userDTO.preference.allowTrackingPersonalInfo,
|
|
user.preferences?.allow_tracking,
|
|
);
|
|
if (!user.preferences.language) {
|
|
user.preferences.language = coalesce(userDTO.preference.locale, user.preferences?.language);
|
|
}
|
|
user.preferences.timezone = coalesce(userDTO.preference.timeZone, user.preferences?.timezone);
|
|
}
|
|
|
|
if (userDTO.avatar) {
|
|
user.picture = userDTO.avatar.value;
|
|
}
|
|
|
|
await gr.services.users.save(user);
|
|
|
|
// [Workavia] Resolve the Company from the user's email domain so
|
|
// each tenant of a Workavia install gets its own isolated Company.
|
|
// Upstream behaviour was a single shared Company.
|
|
const tenant = workaviaCompanyForEmail(userDTO.email);
|
|
let company = await gr.services.companies.getCompany({ id: tenant.id });
|
|
if (!company) {
|
|
company = await gr.services.companies.createCompany(
|
|
getCompanyInstance({
|
|
id: tenant.id,
|
|
name: tenant.name,
|
|
plan: { name: "Local", limits: undefined, features: undefined },
|
|
}),
|
|
);
|
|
}
|
|
|
|
let userRole: CompanyUserRole = "member";
|
|
if (this.rootAdmins.includes(userDTO.email)) {
|
|
userRole = "admin";
|
|
}
|
|
|
|
await gr.services.companies.setUserRole(company.id, user.id, userRole);
|
|
|
|
await gr.services.users.save(user, { user: { id: user.id, server_request: true } });
|
|
|
|
return user;
|
|
}
|
|
|
|
async removeCompanyUser(consoleUserId: string, company: Company): Promise<void> {
|
|
logger.info("Remote: removeCompanyUser");
|
|
|
|
const user = await gr.services.users.getByConsoleId(consoleUserId);
|
|
if (!user) {
|
|
throw CrudException.notFound(`User ${consoleUserId} doesn't exists`);
|
|
}
|
|
await gr.services.companies.removeUserFromCompany({ id: company.id }, user.id);
|
|
}
|
|
|
|
async removeUser(consoleUserId: string): Promise<void> {
|
|
logger.info("Remote: removeUser");
|
|
|
|
const user = await gr.services.users.getByConsoleId(consoleUserId);
|
|
|
|
if (!user) {
|
|
throw new Error("User does not exists on Tdrive.");
|
|
}
|
|
|
|
await gr.services.users.anonymizeAndDelete(
|
|
{ id: user.id },
|
|
{
|
|
user: { id: user.id, server_request: true },
|
|
},
|
|
);
|
|
}
|
|
|
|
async removeCompany(companySearchKey: CompanySearchKey): Promise<void> {
|
|
logger.info("Remote: removeCompany");
|
|
await gr.services.companies.removeCompany(companySearchKey);
|
|
}
|
|
|
|
async getUserByAccessToken(idToken: string): Promise<ConsoleHookUser> {
|
|
const user = (await this.verifier.verifyIdToken(idToken, this.infos.client_id))?.claims as {
|
|
sub: string;
|
|
email: string;
|
|
family_name: string;
|
|
given_name: string;
|
|
name: string;
|
|
locale?: string;
|
|
picture?: string;
|
|
};
|
|
logger.info(`User from getUserByAccessToken is ${JSON.stringify(user)} for token ${idToken}`);
|
|
return {
|
|
_id: user.sub,
|
|
roles: [] as any,
|
|
email: user.email,
|
|
name: user?.given_name,
|
|
surname: user?.family_name,
|
|
isVerified: true,
|
|
preference: {
|
|
locale: user?.locale,
|
|
timeZone: 0,
|
|
allowTrackingPersonalInfo: true,
|
|
},
|
|
avatar: {
|
|
type: "url",
|
|
value: user?.picture,
|
|
},
|
|
};
|
|
}
|
|
|
|
async updateUserSession(idToken: string): Promise<string> {
|
|
const sessionInfo = (await this.verifier.verifyIdToken(idToken, this.infos.client_id))?.claims;
|
|
// make sure sid claim is present in the token and not empty
|
|
if (sessionInfo.sid) {
|
|
const sessionRepository = gr.services.console.getSessionRepo();
|
|
|
|
// check for existing session
|
|
const existingSession = await sessionRepository.findOne({
|
|
sid: sessionInfo.sid,
|
|
});
|
|
if (existingSession) {
|
|
if (existingSession.revoked_at) {
|
|
throw CrudException.unauthorized(`Session ${sessionInfo.sid} expired`);
|
|
}
|
|
return existingSession.sid;
|
|
} else {
|
|
const sessionBody = new Session();
|
|
sessionBody.sub = sessionInfo.sub;
|
|
sessionBody.sid = sessionInfo.sid;
|
|
await sessionRepository.save(sessionBody);
|
|
return sessionBody.sid;
|
|
}
|
|
} else {
|
|
throw new CrudException("Missing sid claim", 400);
|
|
}
|
|
}
|
|
|
|
async backChannelLogout(logoutToken: string): Promise<void> {
|
|
const payload = await this.verifier.verifyLogoutToken(logoutToken);
|
|
|
|
if (!payload.claims) {
|
|
throw new CrudException("Claims are missing in the jwt", 400);
|
|
}
|
|
|
|
if (!payload.claims.iss) {
|
|
throw new CrudException("Missing required 'iss' claim", 400);
|
|
}
|
|
|
|
if (!payload.claims.aud) {
|
|
throw new CrudException("Missing required 'aud' claim", 400);
|
|
}
|
|
|
|
if (!payload.claims.iat) {
|
|
throw new CrudException("Missing required 'iat' claim", 400);
|
|
}
|
|
|
|
if (!payload.claims.jti) {
|
|
throw new CrudException("Missing required 'jti' claim", 400);
|
|
}
|
|
|
|
if (!payload.claims.events) {
|
|
throw new CrudException("Missing required 'events' claim", 400);
|
|
}
|
|
|
|
if (payload.claims.nonce) {
|
|
throw new CrudException("Nonce claim is prohibited", 400);
|
|
}
|
|
|
|
if (!payload.claims.sub) {
|
|
throw new CrudException("Missing 'sub' claim", 400);
|
|
}
|
|
|
|
if (!payload.claims.sid) {
|
|
throw new CrudException("Missing 'sid' claim", 400);
|
|
}
|
|
|
|
const sessionRepository = gr.services.console.getSessionRepo();
|
|
const session = await sessionRepository.findOne({ sid: payload.claims.sid });
|
|
if (session) {
|
|
session.revoked_at = new Date().getTime();
|
|
await sessionRepository.save(session);
|
|
}
|
|
}
|
|
|
|
async userWasDeletedForceLogout(payload: { userId?: string; email?: string }) {
|
|
const sessionRepository = gr.services.console.getSessionRepo();
|
|
if (!sessionRepository) return;
|
|
|
|
const { userId, email } = payload;
|
|
if (!userId && !email) {
|
|
logger.info("Missing query condition for session");
|
|
return;
|
|
}
|
|
|
|
/* Get sessions by userId or email
|
|
* Use condition to query for each criterias
|
|
* In case DB type is postgres we could not query by using IN as mongodb
|
|
* due to the function validate query of postgres do not allow to use array for column type string
|
|
*/
|
|
let sessions: Session[] = [];
|
|
if (userId) {
|
|
const results = (await sessionRepository.find({ sub: userId })).getEntities();
|
|
sessions = sessions.concat(results);
|
|
}
|
|
if (email) {
|
|
const results = (await sessionRepository.find({ sub: email })).getEntities();
|
|
sessions = sessions.concat(results);
|
|
}
|
|
|
|
logger.info({ sessions }, "Sessions to delete");
|
|
for (const session of sessions)
|
|
if (!session.revoked_at) {
|
|
session.revoked_at = new Date().getTime();
|
|
await sessionRepository.save(session);
|
|
}
|
|
}
|
|
|
|
async verifyJwtSid(sid: string): Promise<void> {
|
|
const sessionRepository = gr.services.console.getSessionRepo();
|
|
if (sid) {
|
|
const session = await sessionRepository.findOne({
|
|
sid,
|
|
});
|
|
if (!session) {
|
|
// fail for not matching session id
|
|
throw new Error(`Session ${sid} not found`);
|
|
} else if (session.revoked_at > 0) {
|
|
throw new Error(`Session ${sid} revoked`);
|
|
}
|
|
} else {
|
|
// fail for missing session id
|
|
throw new Error("Missing session id");
|
|
}
|
|
}
|
|
}
|