diff --git a/.github/workflows/ldap-sync.yml b/.github/workflows/ldap-sync.yml new file mode 100644 index 000000000..54088a891 --- /dev/null +++ b/.github/workflows/ldap-sync.yml @@ -0,0 +1,15 @@ +name: ldap-sync-build + +on: + pull_request: + branches: [main] + paths: + - "tdrive/backend/utils/**" + +jobs: + ldap-sync-build: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + - name: Build ldap sync + run: cd tdrive/backend/utils/ldap-sync && npm i && npm run build \ No newline at end of file diff --git a/.github/workflows/publish-ldap-sync.yml b/.github/workflows/publish-ldap-sync.yml new file mode 100644 index 000000000..25990197c --- /dev/null +++ b/.github/workflows/publish-ldap-sync.yml @@ -0,0 +1,32 @@ +name: publish-ldap-sync + +on: + push: + branches: [main] + paths: + - "tdrive/backend/utils/ldap-sync/**" + - "tdrive/docker/**" + +jobs: + publish-node: + runs-on: ubuntu-20.04 + steps: + - name: Set env to production + if: endsWith(github.ref, '/main') + run: 'echo "DOCKERTAG=latest" >> $GITHUB_ENV' + - name: "Push to the registry following labels:" + run: | + echo "${{ env.DOCKERTAG }},${{ env.DOCKERTAGVERSION }}" + - uses: actions/checkout@v2 + - name: Publish to Registry + uses: elgohr/Publish-Docker-Github-Action@v5 + with: + name: tdrive/tdrive-ldap-sync + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + workdir: tdrive + registry: docker-registry.linagora.com + context: . + target: production + buildoptions: "-t docker-registry.linagora.com/tdrive/tdrive-ldap-sync -f docker/tdrive-ldap-sync/Dockerfile" + tags: "${{ env.DOCKERTAG }},${{ env.DOCKERTAGVERSION }}" \ No newline at end of file diff --git a/tdrive/backend/node/config/custom-environment-variables.json b/tdrive/backend/node/config/custom-environment-variables.json index 1bf1e9de8..bc085ec8d 100644 --- a/tdrive/backend/node/config/custom-environment-variables.json +++ b/tdrive/backend/node/config/custom-environment-variables.json @@ -27,7 +27,8 @@ "websocket": { "auth": { "jwt": { - "secret": "AUTH_JWT_SECRET" + "secret": "AUTH_JWT_SECRET", + "expiration": "AUTH_JWT_EXPIRATION" } } }, diff --git a/tdrive/backend/node/config/default.json b/tdrive/backend/node/config/default.json index 78da89771..33924c1b2 100644 --- a/tdrive/backend/node/config/default.json +++ b/tdrive/backend/node/config/default.json @@ -81,7 +81,7 @@ } }, "database":{ - "secret":"ab63bb3e90c0271c9a1c06651a7c0967eab8851a7a897766", + "secret":"", "type":"cassandra", "mongodb":{ "uri":"mongodb://mongo:27017", diff --git a/tdrive/backend/node/package-lock.json b/tdrive/backend/node/package-lock.json index a440435f6..292f35baa 100644 --- a/tdrive/backend/node/package-lock.json +++ b/tdrive/backend/node/package-lock.json @@ -504,6 +504,22 @@ } } }, + "@fastify/cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-6.0.0.tgz", + "integrity": "sha512-Luy3Po3dOJmqAuPCiPcWsX0tV5+C3AOnULSdlsGjNGOvyE7jqzysp8kT9ICfsUvove+TeUMgTWl1y9XS3ZPPMg==", + "requires": { + "cookie-signature": "^1.1.0", + "fastify-plugin": "^3.0.1" + }, + "dependencies": { + "fastify-plugin": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-3.0.1.tgz", + "integrity": "sha512-qKcDXmuZadJqdTm6vlCqioEbyewF60b/0LOFCcYN1B6BIZGlYJumWWOYs70SFYLDAH4YqdE1cxH/RKMG7rFxgA==" + } + } + }, "@fastify/error": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-2.0.0.tgz", @@ -3152,6 +3168,11 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==" }, + "cookie-signature": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.1.tgz", + "integrity": "sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw==" + }, "core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", diff --git a/tdrive/backend/node/package.json b/tdrive/backend/node/package.json index 7cbd0b83c..81eeb8e76 100644 --- a/tdrive/backend/node/package.json +++ b/tdrive/backend/node/package.json @@ -105,6 +105,7 @@ "@fastify/caching": "^7.0.0", "@fastify/formbody": "^6.0.0", "@fastify/static": "^5.0.1", + "@fastify/cookie": "^6.0.0", "@ffprobe-installer/ffprobe": "^1.4.1", "@sentry/node": "^6.19.7", "@sentry/tracing": "^6.19.7", diff --git a/tdrive/backend/node/src/core/platform/services/auth/web/jwt.ts b/tdrive/backend/node/src/core/platform/services/auth/web/jwt.ts index 8af60b6f8..ee0c3661b 100644 --- a/tdrive/backend/node/src/core/platform/services/auth/web/jwt.ts +++ b/tdrive/backend/node/src/core/platform/services/auth/web/jwt.ts @@ -1,12 +1,18 @@ import { FastifyPluginCallback, FastifyRequest } from "fastify"; import fastifyJwt from "fastify-jwt"; +import cookie from "@fastify/cookie"; import fp from "fastify-plugin"; import config from "../../../../config"; import { JwtType } from "../../types"; const jwtPlugin: FastifyPluginCallback = (fastify, _opts, next) => { + fastify.register(cookie); fastify.register(fastifyJwt, { secret: config.get("auth.jwt.secret"), + cookie: { + cookieName: "X-AuthToken", + signed: false, + }, }); const authenticate = async (request: FastifyRequest) => { diff --git a/tdrive/backend/node/src/core/platform/services/webserver/error.ts b/tdrive/backend/node/src/core/platform/services/webserver/error.ts index f3a032791..b13bbaa00 100644 --- a/tdrive/backend/node/src/core/platform/services/webserver/error.ts +++ b/tdrive/backend/node/src/core/platform/services/webserver/error.ts @@ -9,7 +9,7 @@ function serverErrorHandler(server: FastifyInstance): void { ? { statusCode: reply.statusCode, error: "Internal Server Error", - message: "Something went wrong", + message: "Something went wrong, " + err.message, requestId: request.id, } : err, diff --git a/tdrive/backend/node/src/services/applications-api/web/controllers/index.ts b/tdrive/backend/node/src/services/applications-api/web/controllers/index.ts index 22daf1fde..d014ae91b 100644 --- a/tdrive/backend/node/src/services/applications-api/web/controllers/index.ts +++ b/tdrive/backend/node/src/services/applications-api/web/controllers/index.ts @@ -8,18 +8,19 @@ import { RealtimeBaseBusEvent, } from "../../../../core/platform/services/realtime/types"; import { ResourceGetResponse } from "../../../../utils/types"; -import { getInstance } from "../../../user/entities/user"; import { ApplicationObject, getApplicationObject, } from "../../../applications/entities/application"; import gr from "../../../global-resolver"; +import { logger } from "../../../../core/platform/framework/logger"; import { ApplicationApiExecutionContext, ApplicationLoginRequest, ApplicationLoginResponse, ConfigureRequest, } from "../types"; +import { ConsoleHookUser } from "src/services/console/types"; export class ApplicationsApiController { async token( @@ -171,45 +172,18 @@ export class ApplicationsApiController { email: string; first_name: string; last_name: string; - application_id: string; - company_id: string; }; }>, ): Promise { - const email = request.body.email.trim().toLocaleLowerCase(); - const checkApplication = gr.services.applications.companyApps.get({ - application_id: request.body.application_id, - company_id: request.body.company_id, - }); - - if (!checkApplication) { - throw new Error("Application is not allowed to sync users for this company."); - } - - if (await gr.services.users.getByEmail(email)) { - throw new Error("This email is already used"); - } - try { - const newUser = getInstance({ - first_name: request.body.first_name, - last_name: request.body.last_name, - email_canonical: email, - username_canonical: (email.replace("@", ".") || "").toLocaleLowerCase(), - phone: "", - identity_provider: "console", - identity_provider_id: email, - mail_verified: true, - }); - const user = await gr.services.users.create(newUser); - - await gr.services.companies.setUserRole(request.body.company_id, user.entity.id, "admin"); - - await gr.services.users.save(user.entity, { - user: { id: user.entity.id, server_request: true }, - }); + await gr.services.console.getClient().updateLocalUserFromConsole({ + email: request.body.email.trim().toLocaleLowerCase(), + name: request.body.first_name, + surname: request.body.last_name, + } as ConsoleHookUser); } catch (err) { - throw new Error("An unknown error occured"); + logger.error(err); + throw err; } return {}; } diff --git a/tdrive/backend/node/src/services/console/clients/remote.ts b/tdrive/backend/node/src/services/console/clients/remote.ts index b23268955..b0e249424 100644 --- a/tdrive/backend/node/src/services/console/clients/remote.ts +++ b/tdrive/backend/node/src/services/console/clients/remote.ts @@ -1,4 +1,3 @@ -import { AxiosInstance } from "axios"; import { ConsoleServiceClient } from "../client-interface"; import { ConsoleCompany, @@ -26,7 +25,6 @@ import config from "config"; import { CompanyUserRole } from "src/services/user/web/types"; export class ConsoleRemoteClient implements ConsoleServiceClient { version: "1"; - client: AxiosInstance; private infos: ConsoleOptions; private verifier: OidcJwtVerifier; @@ -101,12 +99,13 @@ export class ConsoleRemoteClient implements ConsoleServiceClient { throw CrudException.badRequest("User not found on Console"); } - const roles = userDTO.roles.filter( - role => role.applications === undefined || role.applications.find(a => a.code === "tdrive"), - ); - - //REMOVE LATER - logger.info(`Roles are: ${roles}.`); + 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); @@ -153,7 +152,9 @@ export class ConsoleRemoteClient implements ConsoleServiceClient { user.preferences.timezone = coalesce(userDTO.preference.timeZone, user.preferences?.timezone); } - user.picture = userDTO.avatar.value; + if (userDTO.avatar) { + user.picture = userDTO.avatar.value; + } await gr.services.users.save(user); diff --git a/tdrive/backend/node/src/services/documents/web/routes.ts b/tdrive/backend/node/src/services/documents/web/routes.ts index 53441e5a4..767178684 100644 --- a/tdrive/backend/node/src/services/documents/web/routes.ts +++ b/tdrive/backend/node/src/services/documents/web/routes.ts @@ -83,14 +83,14 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, _options, next) fastify.route({ method: "GET", url: `${serviceUrl}/:id/download`, - preValidation: [fastify.authenticateOptional], + preValidation: [fastify.authenticate], handler: documentsController.download.bind(documentsController), }); fastify.route({ method: "GET", url: `${serviceUrl}/download/zip`, - preValidation: [fastify.authenticateOptional], + preValidation: [fastify.authenticate], handler: documentsController.downloadZip.bind(documentsController), }); diff --git a/tdrive/backend/utils/ldap-sync/.env.example b/tdrive/backend/utils/ldap-sync/.env.example new file mode 100644 index 000000000..748360e8e --- /dev/null +++ b/tdrive/backend/utils/ldap-sync/.env.example @@ -0,0 +1,10 @@ +LDAP_URL=ldap://localhost:389 +LDAP_BIND_DN= +LDAP_BIND_CREDENTIALS= +LDAP_SEARCH_BASE=dc=example,dc=com +LDAP_SEARCH_FILTER=(objectClass=inetorgperson) +API_URL=http://tdrive:4000/api/sync +TDRIVE_URL=http://tdrive:4000/ +TDRIVE_CREDENTIALS_ID=application-name +TDRIVE_CREDENTIALS_SECRET=application-secret +LDAP_ATTRIBUTE_MAPPINGS={"firstName": "givenName", "lastName": "sn", "email": "mail"} diff --git a/tdrive/backend/utils/ldap-sync/.nvmrc b/tdrive/backend/utils/ldap-sync/.nvmrc new file mode 100644 index 000000000..25bf17fc5 --- /dev/null +++ b/tdrive/backend/utils/ldap-sync/.nvmrc @@ -0,0 +1 @@ +18 \ No newline at end of file diff --git a/tdrive/backend/utils/ldap-sync/package.json b/tdrive/backend/utils/ldap-sync/package.json new file mode 100644 index 000000000..1ca7d8808 --- /dev/null +++ b/tdrive/backend/utils/ldap-sync/package.json @@ -0,0 +1,27 @@ +{ + "name": "ldap_project", + "version": "1.0.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "build": "npm run build:clean && npm run build:ts", + "build:ts": "tsc", + "build:clean": "rimraf ./dist", + "sync": "node dist/index.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "axios": "^1.4.0", + "dotenv": "^16.0.3", + "ldapjs": "^3.0.2" + }, + "devDependencies": { + "@types/ldapjs": "^2.2.5", + "typescript": "^5.0.4", + "rimraf": "^3.0.2" + } +} diff --git a/tdrive/backend/utils/ldap-sync/src/index.ts b/tdrive/backend/utils/ldap-sync/src/index.ts new file mode 100644 index 000000000..12ea997ef --- /dev/null +++ b/tdrive/backend/utils/ldap-sync/src/index.ts @@ -0,0 +1,161 @@ +import ldap, { SearchEntry } from "ldapjs"; +import axios, { AxiosError } from "axios"; +import dotenv from "dotenv"; + +interface UserAttributes { + first_name: string; + last_name: string; + email: string; +} + +export interface IApiServiceApplicationTokenRequestParams { + id: string; + secret: string; +} + +export interface IApiServiceApplicationTokenResponse { + resource: { + access_token: { + time: number; + expiration: number; + value: string; + type: string; + }; + }; +} + +dotenv.config(); +console.log("Run script with the following env: "); +console.log(process.env); + +const ldapConfig = { + url: process.env.LDAP_URL || "localhost", + bindDN: process.env.LDAP_BIND_DN || "", + bindCredentials: process.env.LDAP_BIND_CREDENTIALS || "", + searchBase: process.env.LDAP_SEARCH_BASE || "dc=example,dc=com", + searchFilter: process.env.LDAP_SEARCH_FILTER || "(objectClass=inetorgperson)", + mappings: JSON.parse(process.env.LDAP_ATTRIBUTE_MAPPINGS || "{}"), + timeout: 120, + version: 3, +}; + +const tdriveConfig = { + url: process.env.TDRIVE_URL || "http://localhost:4000/)", + credentials: { + id: process.env.TDRIVE_CREDENTIALS_ID || "application-name", + secret: process.env.TDRIVE_CREDENTIALS_SECRET || "application-secret", + } +}; + +const refreshToken = async (): Promise => { + try { + const response = await axios.post( + `${tdriveConfig.url.replace(/\/$/, '')}/api/console/v1/login`, + { + id: tdriveConfig.credentials.id, + secret: tdriveConfig.credentials.secret, + }, + { + headers: { + Authorization: `Basic ${Buffer.from(`${tdriveConfig.credentials.id}:${tdriveConfig.credentials.secret}`).toString('base64')}`, + }, + }, + ); + + const { + resource: { + access_token: { value }, + }, + } = response.data; + + //axiosClient.interceptors.response.use(this.handleResponse, this.handleErrors); + + return value; + } catch (error) { + console.error('failed to get application token', error); + console.info('Using token ', tdriveConfig.credentials.id, tdriveConfig.credentials.secret); + console.info(`POST ${tdriveConfig.url.replace(/\/$/, '')}/api/console/v1/login`); + console.info(`Basic ${Buffer.from(`${tdriveConfig.credentials.id}:${tdriveConfig.credentials.secret}`).toString('base64')}`); + throw new Error("Unable to get access to token, see precious errors for details."); + } +}; + + +// Create LDAP client +const client = ldap.createClient({ + url: ldapConfig.url, +}); + +const accessToken = await refreshToken() + +const axiosClient = axios.create({ + baseURL: tdriveConfig.url, + headers: { + Authorization: `Bearer ${accessToken}`, + }, +}); + +// Bind to LDAP server +client.bind(ldapConfig.bindDN, ldapConfig.bindCredentials, (err) => { + if (err) { + console.error("LDAP bind error:", err); + return; + } + + // Perform search + client.search( + ldapConfig.searchBase, + { + filter: ldapConfig.searchFilter, + attributes: [ldapConfig.mappings.firstName, ldapConfig.mappings.lastName, ldapConfig.mappings.email], + scope: "sub", + derefAliases: 2, + }, + (searchErr, searchRes) => { + if (searchErr) { + console.error("LDAP search error:", searchErr); + return; + } + + const apiRequests: Promise[] = []; + + searchRes.on("searchEntry", (entry: SearchEntry) => { + console.log('Receive entry:: ' + JSON.stringify(entry.attributes)); + + // Handle each search result entry + const userAttributes: UserAttributes = { + first_name: entry.attributes.find(a=> a.type == ldapConfig.mappings.firstName)?.vals[0]!, + last_name: entry.attributes.find(a=> a.type == ldapConfig.mappings.lastName)?.vals[0]!, + email: entry.attributes.find(a=> a.type == ldapConfig.mappings.email)?.vals[0]!, + }; + + if (userAttributes.email) { + //Make API call to tdrive backend with the userAttributes + apiRequests.push(axiosClient.post(process.env.API_URL || "", userAttributes) + .catch((e: AxiosError) => { + console.log(`Error for ${JSON.stringify(userAttributes)}: ${e.message}, body: ${e.response?.data?.message}`); + })); + } else { + console.log(`user ${JSON.stringify(userAttributes)} doesn't have an email`); + } + }); + + searchRes.on("error", (err) => { + console.error("LDAP search result error:", err); + }); + + searchRes.on("end", () => { + // Unbind from LDAP server after search is complete + client.unbind((unbindErr) => { + if (unbindErr) { + console.error("LDAP unbind error:", unbindErr); + } else { + + Promise.allSettled(apiRequests) + .finally(() => console.log("LDAP search COMPLETED.")); + } + }); + }); + } + ); +}); diff --git a/tdrive/backend/utils/ldap-sync/tsconfig.json b/tdrive/backend/utils/ldap-sync/tsconfig.json new file mode 100644 index 000000000..d0ccc9ded --- /dev/null +++ b/tdrive/backend/utils/ldap-sync/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "useUnknownInCatchVariables": false + }, + "include": ["src"] +} + \ No newline at end of file diff --git a/tdrive/docker/tdrive-ldap-sync/Dockerfile b/tdrive/docker/tdrive-ldap-sync/Dockerfile new file mode 100644 index 000000000..f67c71ee2 --- /dev/null +++ b/tdrive/docker/tdrive-ldap-sync/Dockerfile @@ -0,0 +1,15 @@ +# Use an official Node.js runtime as the base image +FROM node:lts-alpine + +# Set the working directory inside the container +WORKDIR /usr/src/app + +# Copy app +COPY backend/utils/ldap-sync/*.json ./ +COPY backend/utils/ldap-sync/src/** ./src/ +COPY backend/utils/ldap-sync/.nvmrc ./ + +RUN npm i && npm run build + +# Run the Node.js application +CMD ["npm", "run", "sync"] diff --git a/tdrive/frontend/public/index.html b/tdrive/frontend/public/index.html index 2da3c6944..a46b747b2 100644 --- a/tdrive/frontend/public/index.html +++ b/tdrive/frontend/public/index.html @@ -10,6 +10,8 @@ content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" /> + +