From 848c6ea853ca499346118deea71e0738e61f7c8a Mon Sep 17 00:00:00 2001 From: Alban Mouton Date: Mon, 3 Feb 2025 14:47:42 +0100 Subject: [PATCH] feat: work on saml config per site --- Dockerfile | 10 ++- api/package.json | 2 +- api/src/auth/providers.ts | 12 +++- api/src/auth/router.ts | 13 ++-- api/src/saml2/service.ts | 104 +++++++++++++++++++++-------- api/src/services.ts | 4 +- api/src/sites/service.ts | 5 ++ api/types/site/schema.js | 5 +- dev/resources/nginx.conf | 1 + docker-compose.yml | 6 +- package-lock.json | 23 +++++-- package.json | 5 +- ui/src/pages/admin/sites/[id].vue | 8 ++- ui/src/pages/admin/sites/index.vue | 2 +- 14 files changed, 143 insertions(+), 57 deletions(-) diff --git a/Dockerfile b/Dockerfile index 297e545c..64c3b814 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ ########################## -FROM node:22.11.0-alpine3.20 AS base +FROM node:22.13.1-alpine3.21 AS base -RUN npm install -g npm@10.9.1 +RUN npm install -g npm@11.1.0 WORKDIR /app ENV NODE_ENV=production @@ -59,7 +59,6 @@ RUN npm -w ui run build ########################## FROM installer AS api-installer -# remove other workspaces and reinstall, otherwise we can get rig have some peer dependencies from other workspaces RUN npm ci -w api --prefer-offline --omit=dev --omit=optional --omit=peer --no-audit --no-fund && \ npx clean-modules --yes "!ramda/src/test.js" RUN mkdir -p /app/api/node_modules @@ -77,8 +76,13 @@ COPY --from=types /app/api/config api/config COPY --from=api-installer /app/api/node_modules api/node_modules COPY --from=ui /app/ui/dist ui/dist ADD package.json README.md LICENSE BUILD.json* ./ + EXPOSE 8080 EXPOSE 9090 + USER node WORKDIR /app/api + +ENV DEBUG upgrade* + CMD ["node", "--max-http-header-size", "65536", "--experimental-strip-types", "index.ts"] diff --git a/api/package.json b/api/package.json index a2753be5..32a4ad65 100644 --- a/api/package.json +++ b/api/package.json @@ -17,7 +17,7 @@ "#i18n": "./i18n/index.ts" }, "dependencies": { - "@data-fair/lib-express": "^1.10.1", + "@data-fair/lib-express": "^1.12.6", "@data-fair/lib-node": "^2.3.1", "@data-fair/lib-utils": "^1.2.0", "@sd/shared": "*", diff --git a/api/src/auth/providers.ts b/api/src/auth/providers.ts index c4e788fb..eb839351 100644 --- a/api/src/auth/providers.ts +++ b/api/src/auth/providers.ts @@ -1,4 +1,4 @@ -import { oauthGlobalProviders, getOidcProviderId, saml2GlobalProviders, getSiteByUrl } from '#services' +import { oauthGlobalProviders, getOidcProviderId, saml2GlobalProviders, getSamlConfigId, getSiteByUrl } from '#services' import type { Site, PublicAuthProvider } from '#types' import _slug from 'slugify' @@ -44,6 +44,16 @@ export const publicSiteProviders = async (site: Site) => { redirectMode: p.redirectMode }) } + if (p.type === 'saml2') { + providers.push({ + type: p.type, + id: getSamlConfigId(p), + title: p.title as string, + color: p.color, + img: p.img, + redirectMode: p.redirectMode + }) + } if (p.type === 'otherSite') { const otherSiteUrl = (p.site.startsWith('http://') || p.site.startsWith('https://')) ? p.site : `https://${p.site}` const otherSite = await getSiteByUrl(otherSiteUrl) diff --git a/api/src/auth/router.ts b/api/src/auth/router.ts index b891c6c2..327d5b56 100644 --- a/api/src/auth/router.ts +++ b/api/src/auth/router.ts @@ -6,7 +6,7 @@ import bodyParser from 'body-parser' import { nanoid } from 'nanoid' import Cookies from 'cookies' import Debug from 'debug' -import { sendMail, postUserIdentityWebhook, getOidcProviderId, oauthGlobalProviders, initOidcProvider, getOAuthProviderById, getOAuthProviderByState, reqSite, getSiteByUrl, check2FASession, is2FAValid, cookie2FAName, getTokenPayload, prepareCallbackUrl, signToken, decodeToken, setSessionCookies, getDefaultUserOrg, logout, keepalive, logoutOAuthToken, readOAuthToken, writeOAuthToken, authCoreProviderMemberInfo, patchCoreOAuthUser, unshortenInvit, getOrgLimits, setNbMembersLimit, getSamlProviderId, saml2GlobalProviders, saml2ServiceProvider, initServerSession, getRedirectSite } from '#services' +import { sendMail, postUserIdentityWebhook, getOidcProviderId, oauthGlobalProviders, initOidcProvider, getOAuthProviderById, getOAuthProviderByState, reqSite, getSiteByUrl, check2FASession, is2FAValid, cookie2FAName, getTokenPayload, prepareCallbackUrl, signToken, decodeToken, setSessionCookies, getDefaultUserOrg, logout, keepalive, logoutOAuthToken, readOAuthToken, writeOAuthToken, authCoreProviderMemberInfo, patchCoreOAuthUser, unshortenInvit, getOrgLimits, setNbMembersLimit, getSamlProviderId, saml2GlobalProviders, saml2ServiceProvider, initServerSession, getRedirectSite, getSamlProviderById } from '#services' import type { SdStorage } from '../storages/interface.ts' import type { ActionPayload, ServerSession, User, UserWritable } from '#types' import eventsLog, { type EventLogContext } from '@data-fair/lib-express/events-log.js' @@ -809,9 +809,9 @@ router.post('/oauth-logout', oauthLogoutCallback) const debugSAML = Debug('saml') // expose metadata to declare ourselves to identity provider -router.get('/saml2-metadata.xml', (req, res) => { +router.get('/saml2-metadata.xml', async (req, res) => { res.type('application/xml') - res.send(saml2ServiceProvider().getMetadata()) + res.send((await saml2ServiceProvider(await reqSite(req))).getMetadata()) }) // starts login @@ -819,7 +819,7 @@ router.get('/saml2/:providerId/login', async (req, res) => { const logContext: EventLogContext = { req } debugSAML('login request', req.params.providerId) - const provider = saml2GlobalProviders().find(p => p.id === req.params.providerId) + const provider = await getSamlProviderById(req, req.params.providerId) if (!provider) { eventsLog.info('sd.auth.saml.fail', 'a user tried to login with an unknown saml provider', logContext) return res.redirect(`${reqSiteUrl(req) + '/simple-directory'}/login?error=unknownSAMLProvider`) @@ -835,7 +835,7 @@ router.get('/saml2/:providerId/login', async (req, res) => { ] // relay state should be a request level parameter but it is not in current version of samlify // cf https://github.com/tngan/samlify/issues/163 - const sp = saml2ServiceProvider() + const sp = await saml2ServiceProvider(await reqSite(req)) sp.entitySetting.relayState = JSON.stringify(relayState) // TODO: apply nameid parameter ? { nameid: req.query.email } @@ -845,6 +845,7 @@ router.get('/saml2/:providerId/login', async (req, res) => { if (typeof req.query.email === 'string') parsedURL.searchParams.append('login_hint', req.query.email) debugSAML('redirect', parsedURL.href) eventsLog.info('sd.auth.saml.redirect', 'a user was redirected to a saml provider', logContext) + console.log('REDIRECT SAMLRequest', parsedURL.searchParams.get('SAMLRequest')) res.redirect(parsedURL.href) }) @@ -855,7 +856,7 @@ router.post('/saml2-assert', async (req, res) => { const site = await reqSite(req) const storage = storages.globalStorage const providers = saml2GlobalProviders() - const sp = saml2ServiceProvider() + const sp = await saml2ServiceProvider(await reqSite(req)) let provider const referer = (req.headers.referer || req.headers.referrer) as string | undefined diff --git a/api/src/saml2/service.ts b/api/src/saml2/service.ts index d5be2fe8..5302aa8c 100644 --- a/api/src/saml2/service.ts +++ b/api/src/saml2/service.ts @@ -1,6 +1,7 @@ // useful tutorial // https://medium.com/disney-streaming/setup-a-single-sign-on-saml-test-environment-with-docker-and-nodejs-c53fc1a984c9 +import type { Request } from 'express' import { readFile, access, constants } from 'node:fs/promises' import type { SAML2 } from '../../config/type/index.ts' import config from '#config' @@ -11,6 +12,8 @@ import mongo from '#mongo' import { decipher, cipher } from '../utils/cipher.ts' import { exec } from 'node:child_process' import { promisify } from 'node:util' +import { getSiteBaseUrl, reqSite } from '#services' +import { type Site } from '#types' const execAsync = promisify(exec) const debug = Debug('saml') @@ -29,6 +32,27 @@ samlify.setSchemaValidator({ } }) +export const getSamlProviderById = async (req: Request, id: string): Promise => { + const site = await reqSite(req) + if (!site) { + return saml2GlobalProviders().find(p => p.id === id) + } else { + const providerInfo = site.authProviders?.find(p => p.type === 'saml2' && getSamlConfigId(p) === id) as SAML2 + const idp = samlify.IdentityProvider(providerInfo) + return { + id, + ...providerInfo, + idp + } + } +} + +export const getSamlConfigId = (providerConfig: SAML2) => { + const idp = samlify.IdentityProvider(providerConfig) + if (!idp.entityMeta.meta.entityID) throw new Error('missing entityID in saml IDP metadata') + return getSamlProviderId(idp.entityMeta.meta.entityID) +} + export const getSamlProviderId = (url: string) => { return slug(new URL(url).host, { lower: true, strict: true }) } @@ -56,8 +80,16 @@ const readDeprecatedCertificates = async (): Promise = } } -const readCertificates = async (): Promise => { - const secret = await mongo.secrets.findOne({ _id: 'saml-certificates' }) +const getSiteCertKey = (site?: Site) => { + if (!site) return 'saml-certificates' + let key = 'saml-certificates-' + slug(site.host) + if (site.path) key += `--${slug(site.path)}` + return key +} + +const readCertificates = async (site?: Site): Promise => { + const key = getSiteCertKey(site) + const secret = await mongo.secrets.findOne({ _id: key }) if (secret) { const certificates = secret.data certificates.signing.privateKey = decipher(certificates.signing.privateKey) @@ -66,52 +98,47 @@ const readCertificates = async (): Promise => { } } -const writeCertificates = async (certificates: Certificates) => { +const writeCertificates = async (certificates: Certificates, site?: Site) => { + const key = getSiteCertKey(site) const storedCertificates = { signing: { ...certificates.signing }, encrypt: { ...certificates.encrypt } } as any storedCertificates.signing.privateKey = cipher(certificates.signing.privateKey) storedCertificates.encrypt.privateKey = cipher(certificates.encrypt.privateKey) - await mongo.secrets.insertOne({ _id: 'saml-certificates', data: storedCertificates }) + await mongo.secrets.insertOne({ _id: key, data: storedCertificates }) } const _globalProviders: PreparedSaml2Provider[] = [] let _sp: samlify.ServiceProviderInstance | undefined -export const saml2ServiceProvider = () => { - if (!_sp) throw new Error('Global Saml 2 providers ware not initialized') +export const saml2ServiceProvider = async (site?: Site) => { + if (!_sp) throw new Error('Global Saml 2 provider was not initialized') + if (site) return await initServiceProvider(site) return _sp } export const saml2GlobalProviders = () => { - if (!_sp) throw new Error('Global Saml 2 providers ware not initialized') + if (!_sp) throw new Error('Global Saml 2 provider was not initialized') return _globalProviders } -export const init = async () => { - let certificates = await readCertificates() +const initCertificates = async (site?: Site) => { + let certificates = await readCertificates(site) if (!certificates) { console.log('Initializing SAML certificates') - certificates = await readDeprecatedCertificates() - if (certificates) { - console.log('Migrating SAML certificates from filesystem to database') - } else { + if (!site) { + certificates = await readDeprecatedCertificates() + if (certificates) { + console.log('Migrating SAML certificates from filesystem to database') + } + } + if (!certificates) { console.log('Generating new SAML certificates') certificates = { signing: await createCert(), encrypt: await createCert() } } - await writeCertificates(certificates) + await writeCertificates(certificates, site) } + return certificates +} - const assertionConsumerService = [{ - Binding: samlify.Constants.namespace.binding.post, - Location: `${config.publicUrl}/api/auth/saml2-assert` - }] - debug('config service provider') - _sp = samlify.ServiceProvider({ - entityID: `${config.publicUrl}/api/auth/saml2-metadata.xml`, - assertionConsumerService, - signingCert: certificates.signing.cert, - privateKey: certificates.signing.privateKey, - encryptCert: certificates.encrypt.cert, - encPrivateKey: certificates.encrypt.privateKey, - ...config.saml2.sp - }) +export const init = async () => { + _sp = await initServiceProvider() debug('config identity providers') for (const providerConfig of config.saml2.providers) { @@ -126,6 +153,27 @@ export const init = async () => { } } +export const initServiceProvider = async (site?: Site) => { + const certificates = await initCertificates(site) + const url = site ? getSiteBaseUrl(site) : config.publicUrl + const assertionConsumerService = [{ + Binding: samlify.Constants.namespace.binding.post, + Location: `${url}/api/auth/saml2-assert` + }] + debug('config service provider') + return samlify.ServiceProvider({ + entityID: `${url}/api/auth/saml2-metadata.xml`, + assertionConsumerService, + signingCert: certificates.signing.cert, + privateKey: certificates.signing.privateKey, + encryptCert: certificates.encrypt.cert, + encPrivateKey: certificates.encrypt.privateKey, + // @ts-ignore if we use a boolean the attribute is set as empty in the xml output, and some IDP don't like that + allowCreate: 'false', + ...config.saml2.sp + }) +} + const createCert = async () => { const subject = `/C=FR/CN=${new URL(config.publicUrl).hostname}` const privateKey = (await execAsync('openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048')).stdout diff --git a/api/src/services.ts b/api/src/services.ts index df822938..f0b5c5a4 100644 --- a/api/src/services.ts +++ b/api/src/services.ts @@ -6,8 +6,8 @@ export * from './limits/service.ts' export * from './mails/service.ts' export { initOidcProvider, oauthGlobalProviders, getOidcProviderId, getOAuthProviderById, getOAuthProviderByState } from './oauth/service.ts' export * from './oauth-tokens/service.ts' -export { saml2ServiceProvider, saml2GlobalProviders, getSamlProviderId } from './saml2/service.ts' -export { reqSite, getSiteByUrl, getRedirectSite } from './sites/service.ts' +export { saml2ServiceProvider, saml2GlobalProviders, getSamlProviderId, getSamlConfigId, getSamlProviderById } from './saml2/service.ts' +export { reqSite, getSiteByUrl, getRedirectSite, getSiteBaseUrl } from './sites/service.ts' export * from './tokens/service.ts' export * from './utils/passwords.ts' export * from './utils/partners.ts' diff --git a/api/src/sites/service.ts b/api/src/sites/service.ts index 84641951..ccc72d0c 100644 --- a/api/src/sites/service.ts +++ b/api/src/sites/service.ts @@ -17,6 +17,11 @@ export const getSiteByUrl = memoize(async (url: string) => { maxAge: 2000 // 2s }) +const publicUrl = new URL(config.publicUrl) +export const getSiteBaseUrl = (site: Site) => { + return `${publicUrl.protocol}://${site.host}${site.path ?? ''}` +} + export const getRedirectSite = async (req: Request, redirect: string) => { const currentSiteUrl = reqSiteUrl(req) const currentSite = await reqSite(req) diff --git a/api/types/site/schema.js b/api/types/site/schema.js index 3ec057cd..82843801 100644 --- a/api/types/site/schema.js +++ b/api/types/site/schema.js @@ -198,7 +198,7 @@ export default { metadata: { type: 'string', title: 'XML metadata', - layout: 'textarea' + layout: { comp: 'textarea', slots: { before: { name: 'saml-help' } } } }, color: { type: 'string', @@ -247,7 +247,8 @@ export default { discovery: { type: 'string', title: 'URL de découverte', - description: 'probablement de la forme http://mon-fournisseur/.well-known/openid-configuration' + description: 'probablement de la forme http://mon-fournisseur/.well-known/openid-configuration', + layout: { slots: { before: { name: 'oidc-help' } } } }, client: { type: 'object', diff --git a/dev/resources/nginx.conf b/dev/resources/nginx.conf index 0abb1e58..7c46fa12 100644 --- a/dev/resources/nginx.conf +++ b/dev/resources/nginx.conf @@ -148,6 +148,7 @@ http { } # another one to simulate multi-site with a path prefix + # to test this one edit devSitePath in vite.config.ts server { listen 6099; server_name _; diff --git a/docker-compose.yml b/docker-compose.yml index 6a4688d7..273ec367 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -66,9 +66,9 @@ services: image: kristophjunge/test-saml-idp:1.15 network_mode: host environment: - - SIMPLESAMLPHP_SP_ENTITY_ID=http://localhost:5689/simple-directory/api/auth/saml2-metadata.xml - - SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE=http://localhost:5689/simple-directory/api/auth/saml2-assert - - SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE=http://localhost:5689/simple-directory/api/auth/saml2-logout + - SIMPLESAMLPHP_SP_ENTITY_ID=http://localhost:5989/simple-directory/api/auth/saml2-metadata.xml + - SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE=http://localhost:5989/simple-directory/api/auth/saml2-assert + - SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE=http://localhost:5989/simple-directory/api/auth/saml2-logout # WARNING: does not work on a recent chrome, this provider tries to use a cookie with samesite=none option and this is not permitted without https # list of users : harley@qlik.example diff --git a/package-lock.json b/package-lock.json index 550b6618..54f84394 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@types/memoizee": "^0.4.11", "@types/mjml": "^4.7.4", "@types/multer": "^1.4.12", + "@types/node": "^22.12.0", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.16", "@types/qrcode": "^1.5.5", @@ -56,7 +57,7 @@ "api": { "name": "@sd/api", "dependencies": { - "@data-fair/lib-express": "^1.10.1", + "@data-fair/lib-express": "^1.12.6", "@data-fair/lib-node": "^2.3.1", "@data-fair/lib-utils": "^1.2.0", "@sd/shared": "*", @@ -641,14 +642,18 @@ "license": "MIT" }, "node_modules/@data-fair/lib-common-types": { - "version": "1.5.3", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@data-fair/lib-common-types/-/lib-common-types-1.5.4.tgz", + "integrity": "sha512-qk2y+ztMrjV9N3T1MJZWMwnrissAXhGVgqRbAT01vM4Kx/1bMGGbiKCNzysLZURIVGVkyuHxfbRgYftY7Sw6fA==", "license": "MIT" }, "node_modules/@data-fair/lib-express": { - "version": "1.10.1", + "version": "1.12.6", + "resolved": "https://registry.npmjs.org/@data-fair/lib-express/-/lib-express-1.12.6.tgz", + "integrity": "sha512-OyG+3ghGVgIny7UuScbMJYcGdNdfPfq13412SzDoFGFhdWZQc1qrkk7kAMEDLZ6tE4oM1yoXjf8H6H/kguAFjg==", "license": "MIT", "dependencies": { - "@data-fair/lib-common-types": "^1.1.0", + "@data-fair/lib-common-types": "^1.5.4", "cookie": "^0.7.2", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.1.0", @@ -1955,10 +1960,12 @@ } }, "node_modules/@types/node": { - "version": "22.7.5", + "version": "22.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.12.0.tgz", + "integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==", "license": "MIT", "dependencies": { - "undici-types": "~6.19.2" + "undici-types": "~6.20.0" } }, "node_modules/@types/node-cron": { @@ -9741,7 +9748,9 @@ } }, "node_modules/undici-types": { - "version": "6.19.8", + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "license": "MIT" }, "node_modules/unhead": { diff --git a/package.json b/package.json index f352c2e7..a05a3574 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@types/memoizee": "^0.4.11", "@types/mjml": "^4.7.4", "@types/multer": "^1.4.12", + "@types/node": "^22.12.0", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.16", "@types/qrcode": "^1.5.5", @@ -71,8 +72,8 @@ "dependencies": { "@data-fair/lib-types-builder": "^1.6.0", "@koumoul/vjsf-compiler": "^0.2.2", - "@types/vue-cropperjs": "^4.1.6", - "@types/debug": "^4.1.12" + "@types/debug": "^4.1.12", + "@types/vue-cropperjs": "^4.1.6" }, "relativeDependencies": { "@data-fair/lib-vuetify": "../lib/packages/vuetify", diff --git a/ui/src/pages/admin/sites/[id].vue b/ui/src/pages/admin/sites/[id].vue index d24d28bc..404fc600 100644 --- a/ui/src/pages/admin/sites/[id].vue +++ b/ui/src/pages/admin/sites/[id].vue @@ -29,6 +29,12 @@ :dark="context.dark" /> + + @@ -75,7 +81,7 @@ const siteId = useRoute<'/admin/sites/[id]'>().params.id const sites = useFetch<{ count: number, results: SiteWithColorWarnings[] }>($apiPath + '/sites', { query: { showAll: true } }) const site = useFetch($apiPath + '/sites/' + siteId, { query: { showAll: true } }) -const siteHref = computed(() => `http://${site.data.value?.host}${site.data.value?.path ?? ''}`) +const siteHref = computed(() => `${site.data.value?.host.startsWith('localhost:') ? 'http' : 'https'}://${site.data.value?.host}${site.data.value?.path ?? ''}`) const { patchSite } = useStore() diff --git a/ui/src/pages/admin/sites/index.vue b/ui/src/pages/admin/sites/index.vue index 743ce936..23046048 100644 --- a/ui/src/pages/admin/sites/index.vue +++ b/ui/src/pages/admin/sites/index.vue @@ -38,7 +38,7 @@ :href="`http://${props.item.host}${props.item.path ?? ''}`" target="blank" class="text-primary" - >{{ props.item.host }} + >{{ `${props.item.host}${props.item.path ?? ''}` }} {{ props.item._id }}