passport.ts 29 KB

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