passport.ts 30 KB

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