From 4a4ae9d332cff31991d9f63417895fce18717f61 Mon Sep 17 00:00:00 2001 From: Norihito Nakae Date: Tue, 28 Nov 2017 12:46:58 +0900 Subject: [PATCH] Initial support for SAML authentication --- README.md | 4 +- config.json.example | 16 +++++ lib/config/default.js | 16 +++++ lib/config/environment.js | 4 ++ lib/config/index.js | 1 + lib/models/user.js | 9 +++ lib/response.js | 2 + lib/web/auth/index.js | 1 + lib/web/auth/saml/index.js | 96 ++++++++++++++++++++++++++++ package.json | 1 + public/views/index/body.ejs | 4 +- public/views/shared/signin-modal.ejs | 7 +- 12 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 lib/web/auth/saml/index.js diff --git a/README.md b/README.md index a80deef..3213370 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,8 @@ There are some configs you need to change in the files below | HMD_LDAP_SEARCHATTRIBUTES | no example | LDAP attributes to search with | | HMD_LDAP_TLS_CA | `server-cert.pem, root.pem` | Root CA for LDAP TLS in PEM format (use comma to separate) | | HMD_LDAP_PROVIDERNAME | `My institution` | Optional name to be displayed at login form indicating the LDAP provider | +| HMD_SAML_IDPSSOURL | `https://idp.example.com/sso` | authentication endpoint of IdP | +| HMD_SAML_IDPCERT | `/path/to/cert.pem` | certificate file path of IdP in PEM format | | HMD_IMGUR_CLIENTID | no example | Imgur API client id | | HMD_EMAIL | `true` or `false` | set to allow email signin | | HMD_ALLOW_PDF_EXPORT | `true` or `false` | Enable or disable PDF exports | @@ -234,7 +236,7 @@ There are some configs you need to change in the files below | service | settings location | description | | ------- | --------- | ----------- | -| facebook, twitter, github, gitlab, mattermost, dropbox, google, ldap | environment variables or `config.json` | for signin | +| facebook, twitter, github, gitlab, mattermost, dropbox, google, ldap, saml | environment variables or `config.json` | for signin | | imgur, s3 | environment variables or `config.json` | for image upload | | google drive(`google/apiKey`, `google/clientID`), dropbox(`dropbox/appKey`) | `config.json` | for export and import | diff --git a/config.json.example b/config.json.example index bd7ab04..db1dd94 100644 --- a/config.json.example +++ b/config.json.example @@ -75,6 +75,22 @@ "changeme": "See https://nodejs.org/api/tls.html#tls_tls_connect_options_callback" } }, + "saml": { + "idpSsoUrl": "change: authentication endpoint of IdP", + "idpCert": "change: certificate file path of IdP in PEM format", + "issuer": "change or delete: identity of the service provider (default: serverurl)", + "callbackUrl": "change or delete: callback url to consume assertions (default: serverurl+'/auth/saml/callback')", + "identifierFormat": "change or delete: name identifier format (default: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress')", + "groupAttribute": "change or delete: attribute name for group list (ex: memberOf)", + "requiredGroups": [ "change or delete: group names that allowed" ], + "externalGroups": [ "change or delete: group names that not allowed" ], + "attribute": { + "id": "change or delete this: attribute map for `id` (default: NameID)", + "username": "change or delete this: attribute map for `username` (default: NameID)", + "displayName": "change or delete this: attribute map for `displayName` (default: NameID)", + "email": "change or delete this: attribute map for `email` (default: NameID)" + } + }, "imgur": { "clientID": "change this" }, diff --git a/lib/config/default.js b/lib/config/default.js index 273bad0..ff1e3a3 100644 --- a/lib/config/default.js +++ b/lib/config/default.js @@ -98,6 +98,22 @@ module.exports = { searchAttributes: undefined, tlsca: undefined }, + saml: { + idpSsoUrl: undefined, + idpCert: undefined, + issuer: undefined, + callbackUrl: undefined, + identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + groupAttribute: undefined, + externalGroups: [], + requiredGroups: [], + attribute: { + id: undefined, + username: undefined, + displayName: undefined, + email: undefined + } + }, email: true, allowemailregister: true, allowpdfexport: true diff --git a/lib/config/environment.js b/lib/config/environment.js index 0c272f0..e339832 100644 --- a/lib/config/environment.js +++ b/lib/config/environment.js @@ -73,6 +73,10 @@ module.exports = { searchAttributes: process.env.HMD_LDAP_SEARCHATTRIBUTES, tlsca: process.env.HMD_LDAP_TLS_CA }, + saml: { + idpSsoUrl: process.env.HMD_SAML_IDPSSOURL, + idpCert: process.env.HMD_SAML_IDPCERT + }, email: toBooleanConfig(process.env.HMD_EMAIL), allowemailregister: toBooleanConfig(process.env.HMD_ALLOW_EMAIL_REGISTER), allowpdfexport: toBooleanConfig(process.env.HMD_ALLOW_PDF_EXPORT) diff --git a/lib/config/index.js b/lib/config/index.js index addd8ba..3ac3de5 100644 --- a/lib/config/index.js +++ b/lib/config/index.js @@ -92,6 +92,7 @@ config.isGitHubEnable = config.github.clientID && config.github.clientSecret config.isGitLabEnable = config.gitlab.clientID && config.gitlab.clientSecret config.isMattermostEnable = config.mattermost.clientID && config.mattermost.clientSecret config.isLDAPEnable = config.ldap.url +config.isSAMLEnable = config.saml.idpSsoUrl config.isPDFExportEnable = config.allowpdfexport // generate correct path diff --git a/lib/models/user.js b/lib/models/user.js index 27566de..f421fe4 100644 --- a/lib/models/user.js +++ b/lib/models/user.js @@ -143,6 +143,15 @@ module.exports = function (sequelize, DataTypes) { photo = letterAvatars(profile.username) } break + case 'saml': + if (profile.emails[0]) { + photo = 'https://www.gravatar.com/avatar/' + md5(profile.emails[0]) + if (bigger) photo += '?s=400' + else photo += '?s=96' + } else { + photo = letterAvatars(profile.username) + } + break } return photo }, diff --git a/lib/response.js b/lib/response.js index 61ce574..9f3d5a4 100755 --- a/lib/response.js +++ b/lib/response.js @@ -68,6 +68,7 @@ function showIndex (req, res, next) { dropbox: config.isDropboxEnable, google: config.isGoogleEnable, ldap: config.isLDAPEnable, + saml: config.isSAMLEnable, email: config.isEmailEnable, allowemailregister: config.allowemailregister, allowpdfexport: config.allowpdfexport, @@ -100,6 +101,7 @@ function responseHackMD (res, note) { dropbox: config.isDropboxEnable, google: config.isGoogleEnable, ldap: config.isLDAPEnable, + saml: config.isSAMLEnable, email: config.isEmailEnable, allowemailregister: config.allowemailregister, allowpdfexport: config.allowpdfexport diff --git a/lib/web/auth/index.js b/lib/web/auth/index.js index 4b61810..db5ff11 100644 --- a/lib/web/auth/index.js +++ b/lib/web/auth/index.js @@ -37,6 +37,7 @@ if (config.isMattermostEnable) authRouter.use(require('./mattermost')) if (config.isDropboxEnable) authRouter.use(require('./dropbox')) if (config.isGoogleEnable) authRouter.use(require('./google')) if (config.isLDAPEnable) authRouter.use(require('./ldap')) +if (config.isSAMLEnable) authRouter.use(require('./saml')) if (config.isEmailEnable) authRouter.use(require('./email')) // logout diff --git a/lib/web/auth/saml/index.js b/lib/web/auth/saml/index.js new file mode 100644 index 0000000..575c6f3 --- /dev/null +++ b/lib/web/auth/saml/index.js @@ -0,0 +1,96 @@ +'use strict' + +const Router = require('express').Router +const passport = require('passport') +const SamlStrategy = require('passport-saml').Strategy +const config = require('../../../config') +const models = require('../../../models') +const logger = require('../../../logger') +const {urlencodedParser} = require('../../utils') +const fs = require('fs') +const intersection = function (array1, array2) { return array1.filter((n) => array2.includes(n)) } + +let samlAuth = module.exports = Router() + +passport.use(new SamlStrategy({ + callbackUrl: config.saml.callbackUrl || config.serverurl + '/auth/saml/callback', + entryPoint: config.saml.idpSsoUrl, + issuer: config.saml.issuer || config.serverurl, + cert: fs.readFileSync(config.saml.idpCert, 'utf-8'), + identifierFormat: config.saml.identifierFormat +}, function (user, done) { + // check authorization if needed + if (config.saml.externalGroups && config.saml.grouptAttribute) { + var externalGroups = intersection(config.saml.externalGroups, user[config.saml.groupAttribute]) + if (externalGroups.length > 0) { + logger.error('saml permission denied: ' + externalGroups.join(', ')) + return done('Permission denied', null) + } + } + if (config.saml.requiredGroups && config.saml.grouptAttribute) { + if (intersection(config.saml.requiredGroups, user[config.saml.groupAttribute]).length === 0) { + logger.error('saml permission denied') + return done('Permission denied', null) + } + } + // user creation + var uuid = user[config.saml.attribute.id] || user.nameID + var profile = { + provider: 'saml', + id: 'SAML-' + uuid, + username: user[config.saml.attribute.username] || user.nameID, + displayName: user[config.saml.attribute.displayName] || user.nameID, + emails: user[config.saml.attribute.email] ? [user[config.saml.attribute.email]] : [] + } + if (profile.emails.length === 0 && config.saml.identifierFormat === 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress') { + profile.emails.push(user.nameID) + } + var stringifiedProfile = JSON.stringify(profile) + models.User.findOrCreate({ + where: { + profileid: profile.id.toString() + }, + defaults: { + profile: stringifiedProfile + } + }).spread(function (user, created) { + if (user) { + var needSave = false + if (user.profile !== stringifiedProfile) { + user.profile = stringifiedProfile + needSave = true + } + if (needSave) { + user.save().then(function () { + if (config.debug) { logger.debug('user login: ' + user.id) } + return done(null, user) + }) + } else { + if (config.debug) { logger.debug('user login: ' + user.id) } + return done(null, user) + } + } + }).catch(function (err) { + logger.error('saml auth failed: ' + err) + return done(err, null) + }) +})) + +samlAuth.get('/auth/saml', + passport.authenticate('saml', { + successReturnToOrRedirect: config.serverurl + '/', + failureRedirect: config.serverurl + '/' + }) +) + +samlAuth.post('/auth/saml/callback', urlencodedParser, + passport.authenticate('saml', { + successReturnToOrRedirect: config.serverurl + '/', + failureRedirect: config.serverurl + '/' + }) +) + +samlAuth.get('/auth/saml/metadata', function (req, res) { + res.type('application/xml') + res.send(passport._strategy('saml').generateServiceProviderMetadata()) +}) diff --git a/package.json b/package.json index b4942c5..f87a0f5 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "passport-local": "^1.0.0", "passport-oauth2": "^1.4.0", "passport-twitter": "^1.0.4", + "passport-saml": "^0.31.0", "passport.socketio": "^3.7.0", "pdfobject": "^2.0.201604172", "pg": "^6.1.2", diff --git a/public/views/index/body.ejs b/public/views/index/body.ejs index 230eb11..d7b4458 100644 --- a/public/views/index/body.ejs +++ b/public/views/index/body.ejs @@ -15,7 +15,7 @@ <% if(allowAnonymous) { %> <%= __('New guest note') %> <% } %> - <% if(facebook || twitter || github || gitlab || mattermost || dropbox || google || ldap || email) { %> + <% if(facebook || twitter || github || gitlab || mattermost || dropbox || google || ldap || saml || email) { %> <% } %> @@ -48,7 +48,7 @@ <% if (errorMessage && errorMessage.length > 0) { %>
<%= errorMessage %>
<% } %> - <% if(facebook || twitter || github || gitlab || mattermost || dropbox || google || ldap || email) { %> + <% if(facebook || twitter || github || gitlab || mattermost || dropbox || google || ldap || saml || email) { %>
diff --git a/public/views/shared/signin-modal.ejs b/public/views/shared/signin-modal.ejs index 89b542e..7b44cfb 100644 --- a/public/views/shared/signin-modal.ejs +++ b/public/views/shared/signin-modal.ejs @@ -43,7 +43,12 @@ <%= __('Sign in via %s', 'Google') %> <% } %> - <% if((facebook || twitter || github || gitlab || mattermost || dropbox || google) && ldap) { %> + <% if(saml) { %> + + <%= __('Sign in via %s', 'SAML') %> + + <% } %> + <% if((facebook || twitter || github || gitlab || mattermost || dropbox || google || saml) && ldap) { %>
<% }%> <% if(ldap) { %>