diff --git a/apps/meteor/app/cas/server/cas_server.js b/apps/meteor/app/cas/server/cas_server.js index 6f26c4eb20f1..92b5b3976b63 100644 --- a/apps/meteor/app/cas/server/cas_server.js +++ b/apps/meteor/app/cas/server/cas_server.js @@ -6,8 +6,8 @@ import { WebApp } from 'meteor/webapp'; import { RoutePolicy } from 'meteor/routepolicy'; import _ from 'underscore'; import fiber from 'fibers'; -import CAS from 'cas'; import { CredentialTokens } from '@rocket.chat/models'; +import { validate } from '@rocket.chat/cas-validate'; import { logger } from './cas_rocketchat'; import { settings } from '../../settings/server'; @@ -38,13 +38,12 @@ const casTicket = function (req, token, callback) { const appUrl = Meteor.absoluteUrl().replace(/\/$/, '') + __meteor_runtime_config__.ROOT_URL_PATH_PREFIX; logger.debug(`Using CAS_base_url: ${baseUrl}`); - const cas = new CAS({ - base_url: baseUrl, - version: cas_version, - service: `${appUrl}/_cas/${token}`, - }); - - cas.validate( + validate( + { + base_url: baseUrl, + version: cas_version, + service: `${appUrl}/_cas/${token}`, + }, ticketId, Meteor.bindEnvironment(async function (err, status, username, details) { if (err) { diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 9769300d6765..449b082934b8 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -194,6 +194,7 @@ "@rocket.chat/agenda": "workspace:^", "@rocket.chat/api-client": "workspace:^", "@rocket.chat/apps-engine": "alpha", + "@rocket.chat/cas-validate": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/css-in-js": "~0.31.14", "@rocket.chat/emitter": "~0.31.14", @@ -245,7 +246,6 @@ "bson": "^4.6.4", "busboy": "^1.6.0", "bytebuffer": "5.0.1", - "cas": "https://github.com/kcbanner/node-cas/tarball/fcd27dad333223b3b75a048bce27973fb3ca0f62", "change-case": "^4.1.2", "chart.js": "^3.8.0", "clipboard": "^2.0.11", diff --git a/packages/cas-validate/package.json b/packages/cas-validate/package.json new file mode 100644 index 000000000000..d01f865afe10 --- /dev/null +++ b/packages/cas-validate/package.json @@ -0,0 +1,36 @@ +{ + "name": "@rocket.chat/cas-validate", + "description": "Fork of https://github.com/kcbanner/node-cas", + "version": "0.0.1", + "private": true, + "devDependencies": { + "@types/jest": "^27.4.1", + "eslint": "^8.12.0", + "jest": "^27.5.1", + "ts-jest": "^27.1.4", + "typescript": "~4.3.4" + }, + "scripts": { + "lint": "eslint --ext .js,.jsx,.ts,.tsx .", + "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", + "jest": "jest", + "build": "rm -rf dist && tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" + }, + "main": "./dist/index.js", + "typings": "./dist/index.d.ts", + "files": [ + "/dist" + ], + "dependencies": { + "cheerio": "1.0.0-rc.10" + }, + "eslintConfig": { + "extends": [ + "@rocket.chat/eslint-config" + ], + "ignorePatterns": [ + "**/dist" + ] + } +} diff --git a/packages/cas-validate/src/index.ts b/packages/cas-validate/src/index.ts new file mode 100644 index 000000000000..c1e396d956c6 --- /dev/null +++ b/packages/cas-validate/src/index.ts @@ -0,0 +1 @@ +export * from './validate'; diff --git a/packages/cas-validate/src/validate.ts b/packages/cas-validate/src/validate.ts new file mode 100644 index 000000000000..c509b22ddff7 --- /dev/null +++ b/packages/cas-validate/src/validate.ts @@ -0,0 +1,285 @@ +import https from 'https'; +import url from 'url'; +import type { IncomingMessage } from 'http'; + +import { load, Cheerio, CheerioAPI } from 'cheerio'; + +export type CasOptions = { + base_url: string; + service?: string; + version: 1.0 | 2.0; +}; + +export type CasCallbackExtendedData = { + username?: unknown; + attributes?: unknown; + PGTIOU?: unknown; + ticket?: unknown; + proxies?: unknown; +}; + +export type CasCallback = (err: any, status?: unknown, username?: unknown, extended?: CasCallbackExtendedData) => void; + +function parseJasigAttributes(elemAttribute: Cheerio, cheerio: CheerioAPI): Record { + // "Jasig Style" Attributes: + // + // + // + // jsmith + // + // RubyCAS + // Smith + // John + // CN=Staff,OU=Groups,DC=example,DC=edu + // CN=Spanish Department,OU=Departments,... + // + // PGTIOU-84678-8a9d2... + // + // + + const attributes: Record = {}; + for (let i = 0; i < elemAttribute.children().length; i++) { + const node = elemAttribute.children()[i]; + const attrName = node.name.toLowerCase().replace(/cas:/, ''); + if (attrName !== '#text') { + const attrValue = cheerio(node).text(); + if (!attributes[attrName]) { + attributes[attrName] = [attrValue]; + } else { + attributes[attrName].push(attrValue); + } + } + } + + return attributes; +} + +function parseRubyCasAttributes(elemSuccess: Cheerio, cheerio: CheerioAPI): Record { + // "RubyCAS Style" attributes + // + // + // + // jsmith + // + // RubyCAS + // Smith + // John + // CN=Staff,OU=Groups,DC=example,DC=edu + // CN=Spanish Department,OU=Departments,... + // + // PGTIOU-84678-8a9d2... + // + // + + const attributes: Record = {}; + for (let i = 0; i < elemSuccess.children().length; i++) { + const node = elemSuccess.children()[i]; + const tagName = node.name.toLowerCase().replace(/cas:/, ''); + switch (tagName) { + case 'user': + case 'proxies': + case 'proxygrantingticket': + case '#text': + // these are not CAS attributes + break; + default: + const attrName = tagName; + const attrValue = cheerio(node).text(); + if (attrValue !== '') { + if (!attributes[attrName]) { + attributes[attrName] = [attrValue]; + } else { + attributes[attrName].push(attrValue); + } + } + break; + } + } + + return attributes; +} + +function parseAttributes(elemSuccess: Cheerio, cheerio: CheerioAPI): Record { + const elemAttribute = elemSuccess.find('cas\\:attributes').first(); + const isJasig = elemAttribute?.children().length > 0; + const attributes = isJasig ? parseJasigAttributes(elemAttribute, cheerio) : parseRubyCasAttributes(elemSuccess, cheerio); + + if (Object.keys(attributes).length > 0) { + return attributes; + } + + // "Name-Value" attributes. + // + // Attribute format from this mailing list thread: + // http://jasig.275507.n4.nabble.com/CAS-attributes-and-how-they-appear-in-the-CAS-response-td264272.html + // Note: This is a less widely used format, but in use by at least two institutions. + // + // + // + // jsmith + // + // + // + // + // + // + // + // PGTIOU-84678-8a9d2sfa23casd + // + // + // + const nodes = elemSuccess.find('cas\\:attribute'); + if (nodes?.length) { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const attrName = node.attribs.name; + const attrValue = node.attribs.value; + + if (!attributes[attrName]) { + attributes[attrName] = [attrValue]; + } else { + attributes[attrName].push(attrValue); + } + } + } + + return attributes; +} + +export function validate(options: CasOptions, ticket: string, callback: CasCallback, renew = false): void { + if (!options.base_url) { + throw new Error('Required CAS option `base_url` missing.'); + } + + const casUrl = url.parse(options.base_url); + if (casUrl.protocol !== 'https:') { + throw new Error('Only https CAS servers are supported.'); + } + + if (!casUrl.hostname) { + throw new Error('Option `base_url` must be a valid url like: https://example.com/cas'); + } + const { service, version = 1.0 } = options; + if (!service) { + throw new Error('Required CAS option `service` missing.'); + } + + const { hostname, port = '443', pathname = '' } = casUrl; + const validatePath = version < 2.0 ? 'validate' : 'proxyValidate'; + + const query = { + ticket, + service, + ...(renew ? { renew: 1 } : {}), + }; + + const queryPath = url.format({ + pathname: `${pathname}/${validatePath}`, + query, + }); + + const req = https.get( + { + host: hostname, + port, + path: queryPath, + rejectUnauthorized: true, + }, + function (res: IncomingMessage) { + // Handle server errors + res.on('error', function (e) { + callback(e); + }); + + // Read result + res.setEncoding('utf8'); + let response = ''; + res.on('data', function (chunk) { + response += chunk; + if (response.length > 1e6) { + req.connection?.destroy(); + } + }); + + res.on('end', function () { + if (version < 2.0) { + const sections = response.split('\n'); + if (sections.length >= 1) { + switch (sections[0]) { + case 'no': + return callback(undefined, false); + case 'yes': + if (sections.length >= 2) { + return callback(undefined, true, sections[1]); + } + } + } + + return callback(new Error('Bad response format.')); + } + + // Use cheerio to parse the XML repsonse. + const cheerio = load(response); + + // Check for auth success + const elemSuccess = cheerio('cas\\:authenticationSuccess').first(); + if (elemSuccess && elemSuccess.length > 0) { + const elemUser = elemSuccess.find('cas\\:user').first(); + if (!elemUser || elemUser.length < 1) { + // This should never happen + callback(new Error('No username?'), false); + return; + } + + // Got username + const username = elemUser.text(); + + // Look for optional proxy granting ticket + let pgtIOU; + const elemPGT = elemSuccess.find('cas\\:proxyGrantingTicket').first(); + if (elemPGT) { + pgtIOU = elemPGT.text(); + } + + // Look for optional proxies + const proxies = []; + const elemProxies = elemSuccess.find('cas\\:proxies'); + for (let i = 0; i < elemProxies.length; i++) { + proxies.push(cheerio(elemProxies[i]).text().trim()); + } + + // Look for optional attributes + const attributes = parseAttributes(elemSuccess, cheerio); + + callback(undefined, true, username, { + username, + attributes, + PGTIOU: pgtIOU, + ticket, + proxies, + }); + return; + } // end if auth success + + // Check for correctly formatted auth failure message + const elemFailure = cheerio('cas\\:authenticationFailure').first(); + if (elemFailure && elemFailure.length > 0) { + const code = elemFailure.attr('code'); + const message = `Validation failed [${code}]: ${elemFailure.text()}`; + callback(new Error(message), false); + return; + } + + // The response was not in any expected format, error + callback(new Error('Bad response format.')); + console.error(response); + }); + }, + ); + + // Connection error with the CAS server + req.on('error', function (err) { + callback(err); + req.abort(); + }); +} diff --git a/packages/cas-validate/tsconfig.json b/packages/cas-validate/tsconfig.json new file mode 100644 index 000000000000..455edb8149c4 --- /dev/null +++ b/packages/cas-validate/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src/**/*"] +} diff --git a/yarn.lock b/yarn.lock index 02397fb4cbee..c7badf9ae187 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4381,11 +4381,6 @@ __metadata: moment-timezone: ~0.5.27 ts-jest: ^27.1.4 typescript: ~4.3.4 - peerDependencies: - cron: ~1.8.0 - date.js: ~0.3.3 - debug: ~4.1.1 - moment-timezone: ~0.5.27 languageName: unknown linkType: soft @@ -4436,6 +4431,19 @@ __metadata: languageName: node linkType: hard +"@rocket.chat/cas-validate@workspace:^, @rocket.chat/cas-validate@workspace:packages/cas-validate": + version: 0.0.0-use.local + resolution: "@rocket.chat/cas-validate@workspace:packages/cas-validate" + dependencies: + "@types/jest": ^27.4.1 + cheerio: 1.0.0-rc.10 + eslint: ^8.12.0 + jest: ^27.5.1 + ts-jest: ^27.1.4 + typescript: ~4.3.4 + languageName: unknown + linkType: soft + "@rocket.chat/core-typings@workspace:^, @rocket.chat/core-typings@workspace:packages/core-typings": version: 0.0.0-use.local resolution: "@rocket.chat/core-typings@workspace:packages/core-typings" @@ -4970,6 +4978,7 @@ __metadata: "@rocket.chat/agenda": "workspace:^" "@rocket.chat/api-client": "workspace:^" "@rocket.chat/apps-engine": alpha + "@rocket.chat/cas-validate": "workspace:^" "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/css-in-js": ~0.31.14 "@rocket.chat/emitter": ~0.31.14 @@ -5097,7 +5106,6 @@ __metadata: bson: ^4.6.4 busboy: ^1.6.0 bytebuffer: 5.0.1 - cas: "https://github.com/kcbanner/node-cas/tarball/fcd27dad333223b3b75a048bce27973fb3ca0f62" chai: ^4.3.6 chai-datetime: ^1.8.0 chai-dom: ^1.11.0 @@ -11421,15 +11429,6 @@ __metadata: languageName: node linkType: hard -"cas@https://github.com/kcbanner/node-cas/tarball/fcd27dad333223b3b75a048bce27973fb3ca0f62": - version: 0.0.5 - resolution: "cas@https://github.com/kcbanner/node-cas.git#commit=fcd27dad333223b3b75a048bce27973fb3ca0f62" - dependencies: - cheerio: 0.19.0 - checksum: 3db182211097cb567d1d7331b03d0fb9ab78467d45158df3da869152dcebcb8c226a0cad18037bddcf5367fc80e1f5e575b66ee53c4e7a68e03e3bd48b65d91b - languageName: node - linkType: hard - "case-sensitive-paths-webpack-plugin@npm:^2.3.0": version: 2.4.0 resolution: "case-sensitive-paths-webpack-plugin@npm:2.4.0" @@ -11683,20 +11682,7 @@ __metadata: languageName: node linkType: hard -"cheerio@npm:0.19.0": - version: 0.19.0 - resolution: "cheerio@npm:0.19.0" - dependencies: - css-select: ~1.0.0 - dom-serializer: ~0.1.0 - entities: ~1.1.1 - htmlparser2: ~3.8.1 - lodash: ^3.2.0 - checksum: 32abfd40f8d7fc95a450514a570579d433866697ae31b1215f857d336e30679f95a4c4225a013931261418aeb01f43e3b15309a9e6c8a6c74cc12dc0494579dd - languageName: node - linkType: hard - -"cheerio@npm:^1.0.0-rc.3": +"cheerio@npm:1.0.0-rc.10, cheerio@npm:^1.0.0-rc.3": version: 1.0.0-rc.10 resolution: "cheerio@npm:1.0.0-rc.10" dependencies: @@ -12976,18 +12962,6 @@ __metadata: languageName: node linkType: hard -"css-select@npm:~1.0.0": - version: 1.0.0 - resolution: "css-select@npm:1.0.0" - dependencies: - boolbase: ~1.0.0 - css-what: 1.0 - domutils: 1.4 - nth-check: ~1.0.0 - checksum: 8b222af76573e453d0db3ed2c11b0cdbd391b9d5023906f1c6e045b809ad7ad0235fcb24e3764d8b121b748ac2593584e07992ab70088349e84d3e74d14970f4 - languageName: node - linkType: hard - "css-tree@npm:1.0.0-alpha.37": version: 1.0.0-alpha.37 resolution: "css-tree@npm:1.0.0-alpha.37" @@ -13018,13 +12992,6 @@ __metadata: languageName: node linkType: hard -"css-what@npm:1.0": - version: 1.0.0 - resolution: "css-what@npm:1.0.0" - checksum: c2ae45a0ff003a31b29c03e78b5b35d49a24f763b976edc0ace37d8500613aa33b6fae7b961f4d778b11c7864f1d9d452200dab9339d0438cb1cfa15a133dee9 - languageName: node - linkType: hard - "css-what@npm:^3.2.1": version: 3.4.2 resolution: "css-what@npm:3.4.2" @@ -14143,16 +14110,6 @@ __metadata: languageName: node linkType: hard -"dom-serializer@npm:~0.1.0": - version: 0.1.1 - resolution: "dom-serializer@npm:0.1.1" - dependencies: - domelementtype: ^1.3.0 - entities: ^1.1.1 - checksum: 4f6a3eff802273741931cfd3c800fab4e683236eed10628d6605f52538a6bc0ce4770f3ca2ad68a27412c103ae9b6cdaed3c0a8e20d2704192bde497bc875215 - languageName: node - linkType: hard - "dom-walk@npm:^0.1.0": version: 0.1.2 resolution: "dom-walk@npm:0.1.2" @@ -14174,7 +14131,7 @@ __metadata: languageName: node linkType: hard -"domelementtype@npm:1, domelementtype@npm:^1.3.0, domelementtype@npm:^1.3.1": +"domelementtype@npm:1, domelementtype@npm:^1.3.1": version: 1.3.1 resolution: "domelementtype@npm:1.3.1" checksum: 7893da40218ae2106ec6ffc146b17f203487a52f5228b032ea7aa470e41dfe03e1bd762d0ee0139e792195efda765434b04b43cddcf63207b098f6ae44b36ad6 @@ -14197,15 +14154,6 @@ __metadata: languageName: node linkType: hard -"domhandler@npm:2.3": - version: 2.3.0 - resolution: "domhandler@npm:2.3.0" - dependencies: - domelementtype: 1 - checksum: 721ca27a3b28d1c710697356ba0ecbcc64fe3f0bd61a30eae04a02e6bd7720c7f0e40b9d59938db024a170fedf9b9ebe0c9ba603579b512d87ad4c410d851a94 - languageName: node - linkType: hard - "domhandler@npm:^2.3.0": version: 2.4.2 resolution: "domhandler@npm:2.4.2" @@ -14254,25 +14202,6 @@ __metadata: languageName: node linkType: hard -"domutils@npm:1.4": - version: 1.4.3 - resolution: "domutils@npm:1.4.3" - dependencies: - domelementtype: 1 - checksum: eaea458d7d0de25c01b967cf79d91a85d6aff3a5ecfc704c5150ef975ab732964871b71ca0a0301045f1be0a7d262f288969f404ebf7524a0c125b7e3d707467 - languageName: node - linkType: hard - -"domutils@npm:1.5": - version: 1.5.1 - resolution: "domutils@npm:1.5.1" - dependencies: - dom-serializer: 0 - domelementtype: 1 - checksum: 800d1f9d1c2e637267dae078ff6e24461e6be1baeb52fa70f2e7e7520816c032a925997cd15d822de53ef9896abb1f35e5c439d301500a9cd6b46a395f6f6ca0 - languageName: node - linkType: hard - "domutils@npm:^1.5.1, domutils@npm:^1.7.0": version: 1.7.0 resolution: "domutils@npm:1.7.0" @@ -14648,14 +14577,7 @@ __metadata: languageName: node linkType: hard -"entities@npm:1.0": - version: 1.0.0 - resolution: "entities@npm:1.0.0" - checksum: 41b33ab98fa62b9b258e287dc2ef2a1e22920651b5170ae3cc95d5489f972a0cb64f5ddecb540ad246c85093b0ab0d4ec5f58fa4d579a00f0088705cd0956eb1 - languageName: node - linkType: hard - -"entities@npm:^1.1.1, entities@npm:~1.1.1": +"entities@npm:^1.1.1": version: 1.1.2 resolution: "entities@npm:1.1.2" checksum: d537b02799bdd4784ffd714d000597ed168727bddf4885da887c5a491d735739029a00794f1998abbf35f3f6aeda32ef5c15010dca1817d401903a501b6d3e05 @@ -18374,19 +18296,6 @@ __metadata: languageName: node linkType: hard -"htmlparser2@npm:~3.8.1": - version: 3.8.3 - resolution: "htmlparser2@npm:3.8.3" - dependencies: - domelementtype: 1 - domhandler: 2.3 - domutils: 1.5 - entities: 1.0 - readable-stream: 1.1 - checksum: b6904bbc2c41f44e9c50215fa771387afd1e2ff4798f6d6e8be8df681cb65e43d00b8c1fb23a7382faa5ba25f0448f672e21954f5894f6aed9d292d0c72245fc - languageName: node - linkType: hard - "http-cache-semantics@npm:3.8.1": version: 3.8.1 resolution: "http-cache-semantics@npm:3.8.1" @@ -21876,13 +21785,6 @@ __metadata: languageName: node linkType: hard -"lodash@npm:^3.2.0": - version: 3.10.1 - resolution: "lodash@npm:3.10.1" - checksum: 53065d3712a2fd90b55690c5af19f9625a5bbb2b7876ff76d782ee1dc22618fd4dff191d44a8e165a17b5b81a851c3e884d3b5b25e314422fbe24bb299542685 - languageName: node - linkType: hard - "log-driver@npm:^1.2.7": version: 1.2.7 resolution: "log-driver@npm:1.2.7" @@ -24207,7 +24109,7 @@ __metadata: languageName: node linkType: hard -"nth-check@npm:^1.0.2, nth-check@npm:~1.0.0": +"nth-check@npm:^1.0.2": version: 1.0.2 resolution: "nth-check@npm:1.0.2" dependencies: @@ -27778,7 +27680,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:1.1, readable-stream@npm:1.1.x, readable-stream@npm:~1.1.9": +"readable-stream@npm:1.1.x, readable-stream@npm:~1.1.9": version: 1.1.14 resolution: "readable-stream@npm:1.1.14" dependencies: