passport.ts 33 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088
  1. import urljoin from 'url-join';
  2. import luceneQueryParser from 'lucene-query-parser';
  3. import passport from 'passport';
  4. import LdapStrategy from 'passport-ldapauth';
  5. import { Strategy as LocalStrategy } from 'passport-local';
  6. import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
  7. import { Strategy as GitHubStrategy } from 'passport-github';
  8. import { Strategy as TwitterStrategy } from 'passport-twitter';
  9. import { Strategy as OidcStrategy, Issuer as OIDCIssuer, custom } from 'openid-client';
  10. import { Profile, Strategy as SamlStrategy, VerifiedCallback } from 'passport-saml';
  11. import { BasicStrategy } from 'passport-http';
  12. import { IncomingMessage } from 'http';
  13. import axiosRetry from 'axios-retry';
  14. import pRetry from 'p-retry';
  15. import loggerFactory from '~/utils/logger';
  16. import S2sMessage from '../models/vo/s2s-message';
  17. import { S2sMessageHandlable } from './s2s-messaging/handlable';
  18. const logger = loggerFactory('growi:service:PassportService');
  19. interface IncomingMessageWithLdapAccountInfo extends IncomingMessage {
  20. ldapAccountInfo: any;
  21. }
  22. /**
  23. * the service class of Passport
  24. */
  25. class PassportService implements S2sMessageHandlable {
  26. // see '/lib/form/login.js'
  27. static get USERNAME_FIELD() { return 'loginForm[username]' }
  28. static get PASSWORD_FIELD() { return 'loginForm[password]' }
  29. crowi!: any;
  30. lastLoadedAt?: Date;
  31. /**
  32. * the flag whether LocalStrategy is set up successfully
  33. */
  34. isLocalStrategySetup = false;
  35. /**
  36. * the flag whether LdapStrategy is set up successfully
  37. */
  38. isLdapStrategySetup = false;
  39. /**
  40. * the flag whether GoogleStrategy is set up successfully
  41. */
  42. isGoogleStrategySetup = false;
  43. /**
  44. * the flag whether GitHubStrategy is set up successfully
  45. */
  46. isGitHubStrategySetup = false;
  47. /**
  48. * the flag whether TwitterStrategy is set up successfully
  49. */
  50. isTwitterStrategySetup = false;
  51. /**
  52. * the flag whether OidcStrategy is set up successfully
  53. */
  54. isOidcStrategySetup = false;
  55. /**
  56. * the flag whether SamlStrategy is set up successfully
  57. */
  58. isSamlStrategySetup = false;
  59. /**
  60. * the flag whether BasicStrategy is set up successfully
  61. */
  62. isBasicStrategySetup = false;
  63. /**
  64. * the flag whether serializer/deserializer are set up successfully
  65. */
  66. isSerializerSetup = false;
  67. /**
  68. * the keys of mandatory configs for SAML
  69. */
  70. mandatoryConfigKeysForSaml = [
  71. 'security:passport-saml:entryPoint',
  72. 'security:passport-saml:issuer',
  73. 'security:passport-saml:cert',
  74. 'security:passport-saml:attrMapId',
  75. 'security:passport-saml:attrMapUsername',
  76. 'security:passport-saml:attrMapMail',
  77. ];
  78. setupFunction = {
  79. local: {
  80. setup: 'setupLocalStrategy',
  81. reset: 'resetLocalStrategy',
  82. },
  83. ldap: {
  84. setup: 'setupLdapStrategy',
  85. reset: 'resetLdapStrategy',
  86. },
  87. saml: {
  88. setup: 'setupSamlStrategy',
  89. reset: 'resetSamlStrategy',
  90. },
  91. oidc: {
  92. setup: 'setupOidcStrategy',
  93. reset: 'resetOidcStrategy',
  94. },
  95. basic: {
  96. setup: 'setupBasicStrategy',
  97. reset: 'resetBasicStrategy',
  98. },
  99. google: {
  100. setup: 'setupGoogleStrategy',
  101. reset: 'resetGoogleStrategy',
  102. },
  103. github: {
  104. setup: 'setupGitHubStrategy',
  105. reset: 'resetGitHubStrategy',
  106. },
  107. twitter: {
  108. setup: 'setupTwitterStrategy',
  109. reset: 'resetTwitterStrategy',
  110. },
  111. };
  112. constructor(crowi: any) {
  113. this.crowi = crowi;
  114. }
  115. /**
  116. * @inheritdoc
  117. */
  118. shouldHandleS2sMessage(s2sMessage) {
  119. const { eventName, updatedAt, strategyId } = s2sMessage;
  120. if (eventName !== 'passportServiceUpdated' || updatedAt == null || strategyId == null) {
  121. return false;
  122. }
  123. return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
  124. }
  125. /**
  126. * @inheritdoc
  127. */
  128. async handleS2sMessage(s2sMessage) {
  129. const { configManager } = this.crowi;
  130. const { strategyId } = s2sMessage;
  131. logger.info('Reset strategy by pubsub notification');
  132. await configManager.loadConfigs();
  133. return this.setupStrategyById(strategyId);
  134. }
  135. async publishUpdatedMessage(strategyId) {
  136. const { s2sMessagingService } = this.crowi;
  137. if (s2sMessagingService != null) {
  138. const s2sMessage = new S2sMessage('passportStrategyReloaded', {
  139. updatedAt: new Date(),
  140. strategyId,
  141. });
  142. try {
  143. await s2sMessagingService.publish(s2sMessage);
  144. }
  145. catch (e) {
  146. logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
  147. }
  148. }
  149. }
  150. /**
  151. * get SetupStrategies
  152. *
  153. * @return {Array}
  154. * @memberof PassportService
  155. */
  156. getSetupStrategies() {
  157. const setupStrategies: string[] = [];
  158. if (this.isLocalStrategySetup) { setupStrategies.push('local') }
  159. if (this.isLdapStrategySetup) { setupStrategies.push('ldap') }
  160. if (this.isSamlStrategySetup) { setupStrategies.push('saml') }
  161. if (this.isOidcStrategySetup) { setupStrategies.push('oidc') }
  162. if (this.isBasicStrategySetup) { setupStrategies.push('basic') }
  163. if (this.isGoogleStrategySetup) { setupStrategies.push('google') }
  164. if (this.isGitHubStrategySetup) { setupStrategies.push('github') }
  165. if (this.isTwitterStrategySetup) { setupStrategies.push('twitter') }
  166. return setupStrategies;
  167. }
  168. /**
  169. * get SetupFunction
  170. *
  171. * @return {Object}
  172. * @param {string} authId
  173. */
  174. getSetupFunction(authId) {
  175. return this.setupFunction[authId];
  176. }
  177. /**
  178. * setup strategy by target name
  179. */
  180. async setupStrategyById(authId) {
  181. const func = this.getSetupFunction(authId);
  182. try {
  183. this[func.setup]();
  184. }
  185. catch (err) {
  186. logger.debug(err);
  187. this[func.reset]();
  188. }
  189. this.lastLoadedAt = new Date();
  190. }
  191. /**
  192. * reset LocalStrategy
  193. *
  194. * @memberof PassportService
  195. */
  196. resetLocalStrategy() {
  197. logger.debug('LocalStrategy: reset');
  198. passport.unuse('local');
  199. this.isLocalStrategySetup = false;
  200. }
  201. /**
  202. * setup LocalStrategy
  203. *
  204. * @memberof PassportService
  205. */
  206. setupLocalStrategy() {
  207. this.resetLocalStrategy();
  208. const { configManager } = this.crowi;
  209. const isEnabled = configManager.getConfig('crowi', 'security:passport-local:isEnabled');
  210. // when disabled
  211. if (!isEnabled) {
  212. return;
  213. }
  214. logger.debug('LocalStrategy: setting up..');
  215. const User = this.crowi.model('User');
  216. passport.use(new LocalStrategy(
  217. {
  218. usernameField: PassportService.USERNAME_FIELD,
  219. passwordField: PassportService.PASSWORD_FIELD,
  220. },
  221. (username, password, done) => {
  222. // find user
  223. User.findUserByUsernameOrEmail(username, password, (err, user) => {
  224. if (err) { return done(err) }
  225. // check existence and password
  226. if (!user || !user.isPasswordValid(password)) {
  227. return done(null, false, { message: 'Incorrect credentials.' });
  228. }
  229. return done(null, user);
  230. });
  231. },
  232. ));
  233. this.isLocalStrategySetup = true;
  234. logger.debug('LocalStrategy: setup is done');
  235. }
  236. /**
  237. * reset LdapStrategy
  238. *
  239. * @memberof PassportService
  240. */
  241. resetLdapStrategy() {
  242. logger.debug('LdapStrategy: reset');
  243. passport.unuse('ldapauth');
  244. this.isLdapStrategySetup = false;
  245. }
  246. /**
  247. * Asynchronous configuration retrieval
  248. *
  249. * @memberof PassportService
  250. */
  251. setupLdapStrategy() {
  252. this.resetLdapStrategy();
  253. const config = this.crowi.config;
  254. const { configManager } = this.crowi;
  255. const isLdapEnabled = configManager.getConfig('crowi', 'security:passport-ldap:isEnabled');
  256. // when disabled
  257. if (!isLdapEnabled) {
  258. return;
  259. }
  260. logger.debug('LdapStrategy: setting up..');
  261. passport.use(new LdapStrategy(this.getLdapConfigurationFunc(config, { passReqToCallback: true }),
  262. (req, ldapAccountInfo, done) => {
  263. logger.debug('LDAP authentication has succeeded', ldapAccountInfo);
  264. // store ldapAccountInfo to req
  265. (req as IncomingMessageWithLdapAccountInfo).ldapAccountInfo = ldapAccountInfo;
  266. done(null, ldapAccountInfo);
  267. }));
  268. this.isLdapStrategySetup = true;
  269. logger.debug('LdapStrategy: setup is done');
  270. }
  271. /**
  272. * return attribute name for mapping to username of Crowi DB
  273. *
  274. * @returns
  275. * @memberof PassportService
  276. */
  277. getLdapAttrNameMappedToUsername() {
  278. return this.crowi.configManager.getConfig('crowi', 'security:passport-ldap:attrMapUsername') || 'uid';
  279. }
  280. /**
  281. * return attribute name for mapping to name of Crowi DB
  282. *
  283. * @returns
  284. * @memberof PassportService
  285. */
  286. getLdapAttrNameMappedToName() {
  287. return this.crowi.configManager.getConfig('crowi', 'security:passport-ldap:attrMapName') || '';
  288. }
  289. /**
  290. * return attribute name for mapping to name of Crowi DB
  291. *
  292. * @returns
  293. * @memberof PassportService
  294. */
  295. getLdapAttrNameMappedToMail() {
  296. return this.crowi.configManager.getConfig('crowi', 'security:passport-ldap:attrMapMail') || 'mail';
  297. }
  298. /**
  299. * CAUTION: this method is capable to use only when `req.body.loginForm` is not null
  300. *
  301. * @param {any} req
  302. * @returns
  303. * @memberof PassportService
  304. */
  305. getLdapAccountIdFromReq(req) {
  306. return req.body.loginForm.username;
  307. }
  308. /**
  309. * Asynchronous configuration retrieval
  310. * @see https://github.com/vesse/passport-ldapauth#asynchronous-configuration-retrieval
  311. *
  312. * @param {object} config
  313. * @param {object} opts
  314. * @returns
  315. * @memberof PassportService
  316. */
  317. getLdapConfigurationFunc(config, opts) {
  318. /* eslint-disable no-multi-spaces */
  319. const { configManager } = this.crowi;
  320. // get configurations
  321. const isUserBind = configManager.getConfig('crowi', 'security:passport-ldap:isUserBind');
  322. const serverUrl = configManager.getConfig('crowi', 'security:passport-ldap:serverUrl');
  323. const bindDN = configManager.getConfig('crowi', 'security:passport-ldap:bindDN');
  324. const bindCredentials = configManager.getConfig('crowi', 'security:passport-ldap:bindDNPassword');
  325. const searchFilter = configManager.getConfig('crowi', 'security:passport-ldap:searchFilter') || '(uid={{username}})';
  326. const groupSearchBase = configManager.getConfig('crowi', 'security:passport-ldap:groupSearchBase');
  327. const groupSearchFilter = configManager.getConfig('crowi', 'security:passport-ldap:groupSearchFilter');
  328. const groupDnProperty = configManager.getConfig('crowi', 'security:passport-ldap:groupDnProperty') || 'uid';
  329. /* eslint-enable no-multi-spaces */
  330. // parse serverUrl
  331. // see: https://regex101.com/r/0tuYBB/1
  332. const match = serverUrl.match(/(ldaps?:\/\/[^/]+)\/(.*)?/);
  333. if (match == null || match.length < 1) {
  334. logger.debug('LdapStrategy: serverUrl is invalid');
  335. return (req, callback) => { callback({ message: 'serverUrl is invalid' }) };
  336. }
  337. const url = match[1];
  338. const searchBase = match[2] || '';
  339. logger.debug(`LdapStrategy: url=${url}`);
  340. logger.debug(`LdapStrategy: searchBase=${searchBase}`);
  341. logger.debug(`LdapStrategy: isUserBind=${isUserBind}`);
  342. if (!isUserBind) {
  343. logger.debug(`LdapStrategy: bindDN=${bindDN}`);
  344. logger.debug(`LdapStrategy: bindCredentials=${bindCredentials}`);
  345. }
  346. logger.debug(`LdapStrategy: searchFilter=${searchFilter}`);
  347. logger.debug(`LdapStrategy: groupSearchBase=${groupSearchBase}`);
  348. logger.debug(`LdapStrategy: groupSearchFilter=${groupSearchFilter}`);
  349. logger.debug(`LdapStrategy: groupDnProperty=${groupDnProperty}`);
  350. return (req, callback) => {
  351. // get credentials from form data
  352. const loginForm = req.body.loginForm;
  353. if (!req.form.isValid) {
  354. return callback({ message: 'Incorrect credentials.' });
  355. }
  356. // user bind
  357. const fixedBindDN = (isUserBind)
  358. ? bindDN.replace(/{{username}}/, loginForm.username)
  359. : bindDN;
  360. const fixedBindCredentials = (isUserBind) ? loginForm.password : bindCredentials;
  361. let serverOpt = {
  362. url,
  363. bindDN: fixedBindDN,
  364. bindCredentials: fixedBindCredentials,
  365. searchBase,
  366. searchFilter,
  367. attrMapUsername: this.getLdapAttrNameMappedToUsername(),
  368. attrMapName: this.getLdapAttrNameMappedToName(),
  369. };
  370. if (groupSearchBase && groupSearchFilter) {
  371. serverOpt = Object.assign(serverOpt, { groupSearchBase, groupSearchFilter, groupDnProperty });
  372. }
  373. process.nextTick(() => {
  374. const mergedOpts = Object.assign({
  375. usernameField: PassportService.USERNAME_FIELD,
  376. passwordField: PassportService.PASSWORD_FIELD,
  377. server: serverOpt,
  378. }, opts);
  379. logger.debug('ldap configuration: ', mergedOpts);
  380. // store configuration to req
  381. req.ldapConfiguration = mergedOpts;
  382. callback(null, mergedOpts);
  383. });
  384. };
  385. }
  386. /**
  387. * Asynchronous configuration retrieval
  388. *
  389. * @memberof PassportService
  390. */
  391. setupGoogleStrategy() {
  392. this.resetGoogleStrategy();
  393. const { configManager } = this.crowi;
  394. const isGoogleEnabled = configManager.getConfig('crowi', 'security:passport-google:isEnabled');
  395. // when disabled
  396. if (!isGoogleEnabled) {
  397. return;
  398. }
  399. logger.debug('GoogleStrategy: setting up..');
  400. passport.use(
  401. new GoogleStrategy(
  402. {
  403. clientID: configManager.getConfig('crowi', 'security:passport-google:clientId'),
  404. clientSecret: configManager.getConfig('crowi', 'security:passport-google:clientSecret'),
  405. callbackURL: (this.crowi.appService.getSiteUrl() != null)
  406. ? urljoin(this.crowi.appService.getSiteUrl(), '/passport/google/callback') // auto-generated with v3.2.4 and above
  407. : configManager.getConfig('crowi', 'security:passport-google:callbackUrl'), // DEPRECATED: backward compatible with v3.2.3 and below
  408. skipUserProfile: false,
  409. },
  410. (accessToken, refreshToken, profile, done) => {
  411. if (profile) {
  412. return done(null, profile);
  413. }
  414. return done(null, false);
  415. },
  416. ),
  417. );
  418. this.isGoogleStrategySetup = true;
  419. logger.debug('GoogleStrategy: setup is done');
  420. }
  421. /**
  422. * reset GoogleStrategy
  423. *
  424. * @memberof PassportService
  425. */
  426. resetGoogleStrategy() {
  427. logger.debug('GoogleStrategy: reset');
  428. passport.unuse('google');
  429. this.isGoogleStrategySetup = false;
  430. }
  431. setupGitHubStrategy() {
  432. this.resetGitHubStrategy();
  433. const { configManager } = this.crowi;
  434. const isGitHubEnabled = configManager.getConfig('crowi', 'security:passport-github:isEnabled');
  435. // when disabled
  436. if (!isGitHubEnabled) {
  437. return;
  438. }
  439. logger.debug('GitHubStrategy: setting up..');
  440. passport.use(
  441. new GitHubStrategy(
  442. {
  443. clientID: configManager.getConfig('crowi', 'security:passport-github:clientId'),
  444. clientSecret: configManager.getConfig('crowi', 'security:passport-github:clientSecret'),
  445. callbackURL: (this.crowi.appService.getSiteUrl() != null)
  446. ? urljoin(this.crowi.appService.getSiteUrl(), '/passport/github/callback') // auto-generated with v3.2.4 and above
  447. : configManager.getConfig('crowi', 'security:passport-github:callbackUrl'), // DEPRECATED: backward compatible with v3.2.3 and below
  448. skipUserProfile: false,
  449. },
  450. (accessToken, refreshToken, profile, done) => {
  451. if (profile) {
  452. return done(null, profile);
  453. }
  454. return done(null, false);
  455. },
  456. ),
  457. );
  458. this.isGitHubStrategySetup = true;
  459. logger.debug('GitHubStrategy: setup is done');
  460. }
  461. /**
  462. * reset GitHubStrategy
  463. *
  464. * @memberof PassportService
  465. */
  466. resetGitHubStrategy() {
  467. logger.debug('GitHubStrategy: reset');
  468. passport.unuse('github');
  469. this.isGitHubStrategySetup = false;
  470. }
  471. setupTwitterStrategy() {
  472. this.resetTwitterStrategy();
  473. const { configManager } = this.crowi;
  474. const isTwitterEnabled = configManager.getConfig('crowi', 'security:passport-twitter:isEnabled');
  475. // when disabled
  476. if (!isTwitterEnabled) {
  477. return;
  478. }
  479. logger.debug('TwitterStrategy: setting up..');
  480. passport.use(
  481. new TwitterStrategy(
  482. {
  483. consumerKey: configManager.getConfig('crowi', 'security:passport-twitter:consumerKey'),
  484. consumerSecret: configManager.getConfig('crowi', 'security:passport-twitter:consumerSecret'),
  485. callbackURL: (this.crowi.appService.getSiteUrl() != null)
  486. ? urljoin(this.crowi.appService.getSiteUrl(), '/passport/twitter/callback') // auto-generated with v3.2.4 and above
  487. : configManager.getConfig('crowi', 'security:passport-twitter:callbackUrl'), // DEPRECATED: backward compatible with v3.2.3 and below
  488. skipUserProfile: false,
  489. },
  490. (accessToken, refreshToken, profile, done) => {
  491. if (profile) {
  492. return done(null, profile);
  493. }
  494. return done(null, false);
  495. },
  496. ),
  497. );
  498. this.isTwitterStrategySetup = true;
  499. logger.debug('TwitterStrategy: setup is done');
  500. }
  501. /**
  502. * reset TwitterStrategy
  503. *
  504. * @memberof PassportService
  505. */
  506. resetTwitterStrategy() {
  507. logger.debug('TwitterStrategy: reset');
  508. passport.unuse('twitter');
  509. this.isTwitterStrategySetup = false;
  510. }
  511. async setupOidcStrategy() {
  512. this.resetOidcStrategy();
  513. const { configManager } = this.crowi;
  514. const isOidcEnabled = configManager.getConfig('crowi', 'security:passport-oidc:isEnabled');
  515. // when disabled
  516. if (!isOidcEnabled) {
  517. return;
  518. }
  519. logger.debug('OidcStrategy: setting up..');
  520. // setup client
  521. // extend oidc request timeouts
  522. const OIDC_ISSUER_TIMEOUT_OPTION = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:oidcIssuerTimeoutOption');
  523. // OIDCIssuer.defaultHttpOptions = { timeout: OIDC_ISSUER_TIMEOUT_OPTION };
  524. custom.setHttpOptionsDefaults({
  525. timeout: OIDC_ISSUER_TIMEOUT_OPTION,
  526. });
  527. const issuerHost = configManager.getConfig('crowi', 'security:passport-oidc:issuerHost');
  528. const clientId = configManager.getConfig('crowi', 'security:passport-oidc:clientId');
  529. const clientSecret = configManager.getConfig('crowi', 'security:passport-oidc:clientSecret');
  530. const redirectUri = (configManager.getConfig('crowi', 'app:siteUrl') != null)
  531. ? urljoin(this.crowi.appService.getSiteUrl(), '/passport/oidc/callback')
  532. : configManager.getConfig('crowi', 'security:passport-oidc:callbackUrl'); // DEPRECATED: backward compatible with v3.2.3 and below
  533. // Prevent request timeout error on app init
  534. const oidcIssuer = await this.getOIDCIssuerInstace(issuerHost);
  535. if (oidcIssuer != null) {
  536. logger.debug('Discovered issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
  537. const authorizationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:authorizationEndpoint');
  538. if (authorizationEndpoint) {
  539. oidcIssuer.metadata.authorization_endpoint = authorizationEndpoint;
  540. }
  541. const tokenEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:tokenEndpoint');
  542. if (tokenEndpoint) {
  543. oidcIssuer.metadata.token_endpoint = tokenEndpoint;
  544. }
  545. const revocationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:revocationEndpoint');
  546. if (revocationEndpoint) {
  547. oidcIssuer.metadata.revocation_endpoint = revocationEndpoint;
  548. }
  549. const introspectionEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:introspectionEndpoint');
  550. if (introspectionEndpoint) {
  551. oidcIssuer.metadata.introspection_endpoint = introspectionEndpoint;
  552. }
  553. const userInfoEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:userInfoEndpoint');
  554. if (userInfoEndpoint) {
  555. oidcIssuer.metadata.userinfo_endpoint = userInfoEndpoint;
  556. }
  557. const endSessionEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:endSessionEndpoint');
  558. if (endSessionEndpoint) {
  559. oidcIssuer.metadata.end_session_endpoint = endSessionEndpoint;
  560. }
  561. const registrationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:registrationEndpoint');
  562. if (registrationEndpoint) {
  563. oidcIssuer.metadata.registration_endpoint = registrationEndpoint;
  564. }
  565. const jwksUri = configManager.getConfig('crowi', 'security:passport-oidc:jwksUri');
  566. if (jwksUri) {
  567. oidcIssuer.metadata.jwks_uri = jwksUri;
  568. }
  569. logger.debug('Configured issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
  570. const client = new oidcIssuer.Client({
  571. client_id: clientId,
  572. client_secret: clientSecret,
  573. redirect_uris: [redirectUri],
  574. response_types: ['code'],
  575. });
  576. // prevent error AssertionError [ERR_ASSERTION]: id_token issued in the future
  577. // Doc: https://github.com/panva/node-openid-client/tree/v2.x#allow-for-system-clock-skew
  578. const OIDC_CLIENT_CLOCK_TOLERANCE = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:oidcClientClockTolerance');
  579. client[custom.clock_tolerance] = OIDC_CLIENT_CLOCK_TOLERANCE;
  580. passport.use('oidc', new OidcStrategy(
  581. {
  582. client,
  583. params: { scope: 'openid email profile' },
  584. },
  585. (tokenset, userinfo, done) => {
  586. if (userinfo) {
  587. return done(null, userinfo);
  588. }
  589. return done(null, false);
  590. },
  591. ));
  592. this.isOidcStrategySetup = true;
  593. logger.debug('OidcStrategy: setup is done');
  594. }
  595. }
  596. /**
  597. * reset OidcStrategy
  598. *
  599. * @memberof PassportService
  600. */
  601. resetOidcStrategy() {
  602. logger.debug('OidcStrategy: reset');
  603. passport.unuse('oidc');
  604. this.isOidcStrategySetup = false;
  605. }
  606. /**
  607. * Sanitize issuer Host / URL to match specified format
  608. * Acceptable format : eg. https://hostname.com
  609. * @param issuerHost string
  610. * @returns string URL.origin
  611. */
  612. getOIDCIssuerHostName(issuerHost) {
  613. const protocol = 'https://';
  614. const pattern = /^https?:\/\//i;
  615. // Set protocol if not available on url
  616. const absUrl = !pattern.test(issuerHost) ? `${protocol}${issuerHost}` : issuerHost;
  617. return new URL(absUrl).origin;
  618. }
  619. /**
  620. *
  621. * Check and initialize connection to OIDC issuer host
  622. * Prevent request timeout error on app init
  623. *
  624. * @param issuerHost
  625. * @returns boolean
  626. */
  627. async isOidcHostReachable(issuerHost) {
  628. try {
  629. const hostname = this.getOIDCIssuerHostName(issuerHost);
  630. const client = require('axios').default;
  631. axiosRetry(client, {
  632. retries: 3,
  633. });
  634. const response = await client.get(`${hostname}/.well-known/openid-configuration`);
  635. // Check for valid OIDC Issuer configuration
  636. if (!response.data.issuer) {
  637. logger.debug('OidcStrategy: Invalid OIDC Issuer configurations');
  638. return false;
  639. }
  640. return true;
  641. }
  642. catch (err) {
  643. logger.error('OidcStrategy: issuer host unreachable:', err.code);
  644. }
  645. }
  646. /**
  647. * Get oidcIssuer object
  648. * Utilize p-retry package to retry oidcIssuer initialization 3 times
  649. *
  650. * @param issuerHost
  651. * @returns instance of OIDCIssuer
  652. */
  653. async getOIDCIssuerInstace(issuerHost) {
  654. const OIDC_TIMEOUT_MULTIPLIER = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:timeoutMultiplier');
  655. const OIDC_DISCOVERY_RETRIES = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:discoveryRetries');
  656. const OIDC_ISSUER_TIMEOUT_OPTION = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:oidcIssuerTimeoutOption');
  657. const oidcIssuerHostReady = await this.isOidcHostReachable(issuerHost);
  658. if (!oidcIssuerHostReady) {
  659. logger.error('OidcStrategy: setup failed');
  660. return;
  661. }
  662. const oidcIssuer = await pRetry(async() => {
  663. return OIDCIssuer.discover(issuerHost);
  664. }, {
  665. onFailedAttempt: (error) => {
  666. // get current OIDCIssuer timeout options
  667. OIDCIssuer[custom.http_options] = (url, options) => {
  668. const timeout = options.timeout
  669. ? options.timeout * OIDC_TIMEOUT_MULTIPLIER
  670. : OIDC_ISSUER_TIMEOUT_OPTION * OIDC_TIMEOUT_MULTIPLIER;
  671. custom.setHttpOptionsDefaults({ timeout });
  672. return { timeout };
  673. };
  674. logger.debug(`OidcStrategy: setup attempt ${error.attemptNumber} failed with error: ${error}. Retrying ...`);
  675. },
  676. retries: OIDC_DISCOVERY_RETRIES,
  677. }).catch((error) => {
  678. logger.error(`OidcStrategy: setup failed with error: ${error} `);
  679. });
  680. return oidcIssuer;
  681. }
  682. setupSamlStrategy() {
  683. this.resetSamlStrategy();
  684. const { configManager } = this.crowi;
  685. const isSamlEnabled = configManager.getConfig('crowi', 'security:passport-saml:isEnabled');
  686. // when disabled
  687. if (!isSamlEnabled) {
  688. return;
  689. }
  690. logger.debug('SamlStrategy: setting up..');
  691. passport.use(
  692. new SamlStrategy(
  693. {
  694. entryPoint: configManager.getConfig('crowi', 'security:passport-saml:entryPoint'),
  695. callbackUrl: (this.crowi.appService.getSiteUrl() != null)
  696. ? urljoin(this.crowi.appService.getSiteUrl(), '/passport/saml/callback') // auto-generated with v3.2.4 and above
  697. : configManager.getConfig('crowi', 'security:passport-saml:callbackUrl'), // DEPRECATED: backward compatible with v3.2.3 and below
  698. issuer: configManager.getConfig('crowi', 'security:passport-saml:issuer'),
  699. cert: configManager.getConfig('crowi', 'security:passport-saml:cert'),
  700. },
  701. (profile: Profile, done: VerifiedCallback) => {
  702. if (profile) {
  703. return done(null, profile);
  704. }
  705. return done(null);
  706. },
  707. ),
  708. );
  709. this.isSamlStrategySetup = true;
  710. logger.debug('SamlStrategy: setup is done');
  711. }
  712. /**
  713. * reset SamlStrategy
  714. *
  715. * @memberof PassportService
  716. */
  717. resetSamlStrategy() {
  718. logger.debug('SamlStrategy: reset');
  719. passport.unuse('saml');
  720. this.isSamlStrategySetup = false;
  721. }
  722. /**
  723. * return the keys of the configs mandatory for SAML whose value are empty.
  724. */
  725. getSamlMissingMandatoryConfigKeys() {
  726. const missingRequireds: string[] = [];
  727. for (const key of this.mandatoryConfigKeysForSaml) {
  728. if (this.crowi.configManager.getConfig('crowi', key) === null) {
  729. missingRequireds.push(key);
  730. }
  731. }
  732. return missingRequireds;
  733. }
  734. /**
  735. * Parse Attribute-Based Login Control Rule as Lucene Query
  736. * @param {string} rule Lucene syntax string
  737. * @returns {object} Expression Tree Structure generated by lucene-query-parser
  738. * @see https://github.com/thoward/lucene-query-parser.js/wiki
  739. */
  740. parseABLCRule(rule) {
  741. // parse with lucene-query-parser
  742. // see https://github.com/thoward/lucene-query-parser.js/wiki
  743. return luceneQueryParser.parse(rule);
  744. }
  745. /**
  746. * Verify that a SAML response meets the attribute-base login control rule
  747. */
  748. verifySAMLResponseByABLCRule(response) {
  749. const rule = this.crowi.configManager.getConfig('crowi', 'security:passport-saml:ABLCRule');
  750. if (rule == null) {
  751. logger.debug('There is no ABLCRule.');
  752. return true;
  753. }
  754. const luceneRule = this.parseABLCRule(rule);
  755. logger.debug({ 'Parsed Rule': JSON.stringify(luceneRule, null, 2) });
  756. const attributes = this.extractAttributesFromSAMLResponse(response);
  757. logger.debug({ 'Extracted Attributes': JSON.stringify(attributes, null, 2) });
  758. return this.evaluateRuleForSamlAttributes(attributes, luceneRule);
  759. }
  760. /**
  761. * Evaluate whether the specified rule is satisfied under the specified attributes
  762. *
  763. * @param {object} attributes results by extractAttributesFromSAMLResponse
  764. * @param {object} luceneRule Expression Tree Structure generated by lucene-query-parser
  765. * @see https://github.com/thoward/lucene-query-parser.js/wiki
  766. */
  767. evaluateRuleForSamlAttributes(attributes, luceneRule) {
  768. const { left, right, operator } = luceneRule;
  769. // when combined rules
  770. if (right != null) {
  771. return this.evaluateCombinedRulesForSamlAttributes(attributes, left, right, operator);
  772. }
  773. if (left != null) {
  774. return this.evaluateRuleForSamlAttributes(attributes, left);
  775. }
  776. const { field, term } = luceneRule;
  777. const unescapedField = this.literalUnescape(field);
  778. if (unescapedField === '<implicit>') {
  779. return attributes[term] != null;
  780. }
  781. if (attributes[unescapedField] == null) {
  782. return false;
  783. }
  784. return attributes[unescapedField].includes(term);
  785. }
  786. /**
  787. * Evaluate whether the specified two rules are satisfied under the specified attributes
  788. *
  789. * @param {object} attributes results by extractAttributesFromSAMLResponse
  790. * @param {object} luceneRuleLeft Expression Tree Structure generated by lucene-query-parser
  791. * @param {object} luceneRuleRight Expression Tree Structure generated by lucene-query-parser
  792. * @param {string} luceneOperator operator string expression
  793. * @see https://github.com/thoward/lucene-query-parser.js/wiki
  794. */
  795. evaluateCombinedRulesForSamlAttributes(attributes, luceneRuleLeft, luceneRuleRight, luceneOperator) {
  796. if (luceneOperator === 'OR') {
  797. return this.evaluateRuleForSamlAttributes(attributes, luceneRuleLeft) || this.evaluateRuleForSamlAttributes(attributes, luceneRuleRight);
  798. }
  799. if (luceneOperator === 'AND') {
  800. return this.evaluateRuleForSamlAttributes(attributes, luceneRuleLeft) && this.evaluateRuleForSamlAttributes(attributes, luceneRuleRight);
  801. }
  802. if (luceneOperator === 'NOT') {
  803. return this.evaluateRuleForSamlAttributes(attributes, luceneRuleLeft) && !this.evaluateRuleForSamlAttributes(attributes, luceneRuleRight);
  804. }
  805. throw new Error(`Unsupported operator: ${luceneOperator}`);
  806. }
  807. /**
  808. * Extract attributes from a SAML response
  809. *
  810. * The format of extracted attributes is the following.
  811. *
  812. * {
  813. * "attribute_name1": ["value1", "value2", ...],
  814. * "attribute_name2": ["value1", "value2", ...],
  815. * ...
  816. * }
  817. */
  818. extractAttributesFromSAMLResponse(response) {
  819. const attributeStatement = response.getAssertion().Assertion.AttributeStatement;
  820. if (attributeStatement == null || attributeStatement[0] == null) {
  821. return {};
  822. }
  823. const attributes = attributeStatement[0].Attribute;
  824. if (attributes == null) {
  825. return {};
  826. }
  827. const result = {};
  828. for (const attribute of attributes) {
  829. const name = attribute.$.Name;
  830. const attributeValues = attribute.AttributeValue.map(v => v._);
  831. if (result[name] == null) {
  832. result[name] = attributeValues;
  833. }
  834. else {
  835. result[name] = result[name].concat(attributeValues);
  836. }
  837. }
  838. return result;
  839. }
  840. /**
  841. * reset BasicStrategy
  842. *
  843. * @memberof PassportService
  844. */
  845. resetBasicStrategy() {
  846. logger.debug('BasicStrategy: reset');
  847. passport.unuse('basic');
  848. this.isBasicStrategySetup = false;
  849. }
  850. /**
  851. * setup BasicStrategy
  852. *
  853. * @memberof PassportService
  854. */
  855. setupBasicStrategy() {
  856. this.resetBasicStrategy();
  857. const configManager = this.crowi.configManager;
  858. const isBasicEnabled = configManager.getConfig('crowi', 'security:passport-basic:isEnabled');
  859. // when disabled
  860. if (!isBasicEnabled) {
  861. return;
  862. }
  863. logger.debug('BasicStrategy: setting up..');
  864. passport.use(new BasicStrategy(
  865. (userId, password, done) => {
  866. if (userId != null) {
  867. return done(null, userId);
  868. }
  869. return done(null, false, { message: 'Incorrect credentials.' });
  870. },
  871. ));
  872. this.isBasicStrategySetup = true;
  873. logger.debug('BasicStrategy: setup is done');
  874. }
  875. /**
  876. * setup serializer and deserializer
  877. *
  878. * @memberof PassportService
  879. */
  880. setupSerializer() {
  881. // check whether the serializer/deserializer have already been set up
  882. if (this.isSerializerSetup) {
  883. throw new Error('serializer/deserializer have already been set up');
  884. }
  885. logger.debug('setting up serializer and deserializer');
  886. const User = this.crowi.model('User');
  887. passport.serializeUser((user, done) => {
  888. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  889. done(null, (user as any).id);
  890. });
  891. passport.deserializeUser(async(id, done) => {
  892. try {
  893. const user = await User.findById(id);
  894. if (user == null) {
  895. throw new Error('user not found');
  896. }
  897. if (user.imageUrlCached == null) {
  898. await user.updateImageUrlCached();
  899. await user.save();
  900. }
  901. done(null, user);
  902. }
  903. catch (err) {
  904. done(err);
  905. }
  906. });
  907. this.isSerializerSetup = true;
  908. }
  909. isSameUsernameTreatedAsIdenticalUser(providerType) {
  910. const key = `security:passport-${providerType}:isSameUsernameTreatedAsIdenticalUser`;
  911. return this.crowi.configManager.getConfig('crowi', key);
  912. }
  913. isSameEmailTreatedAsIdenticalUser(providerType) {
  914. const key = `security:passport-${providerType}:isSameEmailTreatedAsIdenticalUser`;
  915. return this.crowi.configManager.getConfig('crowi', key);
  916. }
  917. literalUnescape(string: string) {
  918. return string
  919. .replace(/\\\\/g, '\\')
  920. .replace(/\\\//g, '/')
  921. .replace(/\\:/g, ':')
  922. .replace(/\\"/g, '"')
  923. .replace(/\\0/g, '\0')
  924. .replace(/\\t/g, '\t')
  925. .replace(/\\n/g, '\n')
  926. .replace(/\\r/g, '\r');
  927. }
  928. }
  929. module.exports = PassportService;