passport.ts 31 KB

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