diff --git a/app.js b/app.js index f77e99ca..ebef398d 100755 --- a/app.js +++ b/app.js @@ -15,6 +15,9 @@ const path = require('path'); const sass_middleware = require('node-sass-middleware'); const session = require('cookie-session'); +const passport = require('passport'); +const saml = require('passport-saml'); + // Obtain secret from config file const config = require('./config.js'); @@ -24,6 +27,30 @@ const api = require('./routes/api/index'); const oauth2 = require('./routes/oauth2/oauth2'); const saml2 = require('./routes/saml2/saml2'); +passport.serializeUser(function(user, done) { + done(null, user); +}); +passport.deserializeUser(function(user, done) { + done(null, user); +}); + +const saml_strategy = new saml.Strategy( + { + // config options here + callbackUrl: '/auth/sso/callback', // eslint-disable-line snakecase/snakecase + entryPoint: config.external_user_sso.entry_point, // eslint-disable-line snakecase/snakecase + issuer: config.external_user_sso.issuer, // eslint-disable-line snakecase/snakecase + identifierFormat: null, // eslint-disable-line snakecase/snakecase + validateInResponseTo: false, // eslint-disable-line snakecase/snakecase + disableRequestedAuthnContext: true, // eslint-disable-line snakecase/snakecase + }, + function(profile, done) { + return done(null, profile); + } +); + +passport.use('samlStrategy', saml_strategy); + const app = express(); // view engine setup @@ -99,6 +126,9 @@ app.use( }) ); +app.use(passport.initialize()); +app.use(passport.session()); + // Helpers dinamicos: app.use(function(req, res, next) { res.set( diff --git a/config.js.template b/config.js.template index 718c22b5..f51fd762 100755 --- a/config.js.template +++ b/config.js.template @@ -121,6 +121,14 @@ config.external_auth = { } } +// External user authentication with SAML Profile. +// SAML Profile must include field username and email. +config.external_user_sso = { + enabled: to_boolean(process.env.IDM_EX_AUTH_SSO_ENABLED, false), + entry_point: (process.env.IDM_EX_AUTH_SSO_HOST || 'https://keycloak'), + issuer: (process.env.IDM_EX_AUTH_SSO_ISSUER || 'keyrock') +} + // Email configuration config.mail = { transport: (process.env.IDM_EMAIL_TRANSPORT || 'smtp'), diff --git a/controllers/web/index.js b/controllers/web/index.js index 03ab673f..d092b8d6 100755 --- a/controllers/web/index.js +++ b/controllers/web/index.js @@ -20,4 +20,5 @@ module.exports = { manage_members: require('../../controllers/web/manage_members'), settings: require('../../controllers/web/settings'), sessions: require('../../controllers/web/sessions'), + sso: require('../../controllers/web/sso'), }; diff --git a/controllers/web/sessions.js b/controllers/web/sessions.js index 941e382d..a2d052a4 100755 --- a/controllers/web/sessions.js +++ b/controllers/web/sessions.js @@ -8,6 +8,7 @@ const Sequelize = require('sequelize'); const Op = Sequelize.Op; const escape_paths = require('../../etc/escape_paths/paths.json').paths; +const config = require('../../config'); // MW to authorized restricted http accesses exports.login_required = function(req, res, next) { @@ -77,7 +78,11 @@ exports.new = function(req, res) { res.locals.message = req.session.message; delete req.session.message; } - res.render('index', { errors, csrf_token: req.csrfToken() }); + res.render('index', { + errors, + csrf_token: req.csrfToken(), + sso_enabled: config.external_user_sso.enabled, + }); }; // POST /auth/login -- Create Session @@ -134,7 +139,6 @@ exports.create = function(req, res) { if (user.admin) { req.session.user.admin = user.admin; } - res.redirect('/idm'); }); } else { diff --git a/controllers/web/settings.js b/controllers/web/settings.js index 20cb1be1..4e6aec9e 100755 --- a/controllers/web/settings.js +++ b/controllers/web/settings.js @@ -22,7 +22,11 @@ const email_list = config.email_list_type exports.settings = function(req, res) { debug('--> settings'); - res.render('settings/settings', { csrf_token: req.csrfToken() }); + //res.render('settings/settings', { csrf_token: req.csrfToken() }); + res.render('settings/settings', { + csrf_token: req.csrfToken(), + sso_enabled: config.external_user_sso.enabled, + }); }; // POST /idm/settings/password -- Change password diff --git a/controllers/web/sso.js b/controllers/web/sso.js new file mode 100755 index 00000000..5938568e --- /dev/null +++ b/controllers/web/sso.js @@ -0,0 +1,176 @@ +const models = require('../../models/models.js'); +const config = require('../../config'); +const gravatar = require('gravatar'); +const util = require('util'); + +const debug = require('debug')('idm:web-user_controller'); + +const email = require('../../lib/email.js'); + +// Create new user by email & username in SAML profile +function create_user_from_saml(req, res, callback) { + debug('--> create user from saml'); + + if (!(typeof req.user.email !== 'undefined' && req.user.email)) { + debug('---> SAML Profile: email must not empty'); + req.session.errors = [{ message: 'invalid' }]; + return res.redirect('/auth/login'); + } + + if (!(typeof req.user.username !== 'undefined' && req.user.username)) { + debug('---> SAML Profile: username must not empty'); + req.session.errors = [{ message: 'invalid' }]; + return res.redirect('/auth/login'); + } + + // Build a row and validate it + const user = models.user.build({ + username: req.user.username, + email: req.user.email, + password: 'test', + date_password: new Date(new Date().getTime()), + enabled: true, + }); + + user + .validate() + .then(function() { + debug('---> user is valid'); + // Save the row in the database + user.save().then(function() { + const activation_key = Math.random() + .toString(36) + .substr(2); + const activation_expires = new Date( + new Date().getTime() + 1000 * 3600 * 24 + ); + + models.user_registration_profile + .findOrCreate({ + defaults: { + user_email: user.email, + activation_key, + activation_expires, + }, + where: { user_email: user.email }, + }) + .then(function() { + // Send an email to the user + const link = + config.host + + '/activate?activation_key=' + + activation_key + + '&email=' + + encodeURIComponent(user.email); // eslint-disable-line snakecase/snakecase + + const mail_data = { + name: user.username, + link, + }; + + const translation = req.app.locals.translation; + + // Send an email message to the user + email.send('activate', '', user.email, mail_data, translation); + callback(req, res); + }); + }); + }) + .catch(function(error) { + // print the error details + debug('users is invalid: ' + error); + req.session.errors = [{ message: 'invalid' }]; + res.redirect('/auth/login'); + }); + return undefined; +} + +function find_or_create_user_from_saml(req, res) { + debug('--> find_or_create_user_from_saml'); + debug( + '--> SAML Prifole: ' + + util.inspect(req.user, { showHidden: false, depth: null }) // eslint-disable-line snakecase/snakecase + ); + + if (!(typeof req.user.email !== 'undefined' && req.user.email)) { + debug('---> SAML Profile: email must not empty'); + req.session.errors = [{ message: 'invalid' }]; + return res.redirect('/auth/login'); + } + + models.user + .find({ + attributes: [ + 'id', + 'username', + 'salt', + 'password', + 'enabled', + 'email', + 'gravatar', + 'image', + 'admin', + 'date_password', + 'starters_tour_ended', + ], + where: { + email: req.user.email, + }, + }) + .then(function(user) { + if (user) { + if (user.enabled === false) { + debug('---> user is not enabled'); + req.session.errors = [{ message: 'user_not_found' }]; + res.redirect('/auth/login'); + } + + // Create req.session.user and save id and username + // The session is defined by the existence of: req.session.user + + let image = '/img/logos/small/user.png'; + + if (user.gravatar) { + image = gravatar.url( + user.email, + { s: 100, r: 'g', d: 'mm' }, + { protocol: 'https' } + ); + } else if (user.image === 'default') { + image = '/img/logos/original/user.png'; + } else { + image = '/img/users/' + user.image; + } + + // Create session + req.session.user = { + id: user.id, + username: user.username, + email: user.email, + image, + change_password: user.date_password, + starters_tour_ended: user.starters_tour_ended, + }; + + // If user is admin add parameter to session + if (user.admin) { + req.session.user.admin = user.admin; + } + + res.redirect('/idm'); + } else { + debug('---> user not found & create new user'); + create_user_from_saml(req, res, find_or_create_user_from_saml); + } + }) + .catch(function(error) { + debug('---> user is not found: ' + error); + req.session.errors = [{ message: 'user_not_found' }]; + res.redirect('/auth/login'); + }); + return undefined; +} + +exports.load_user_by_email = function(req, res) { + find_or_create_user_from_saml(req, res); +}; diff --git a/controllers/web/users.js b/controllers/web/users.js index 36c3bf3a..59371320 100755 --- a/controllers/web/users.js +++ b/controllers/web/users.js @@ -295,6 +295,7 @@ exports.edit = function(req, res) { .on('error', function(e) { debug('Failed connecting to gravatar: ' + e); res.render('users/edit', { + identity_attributes, user: req.user, error: [], csrf_token: req.csrfToken(), @@ -307,6 +308,7 @@ exports.edit = function(req, res) { { protocol: 'https' } ); res.render('users/edit', { + identity_attributes, user: req.user, error: [], csrf_token: req.csrfToken(), @@ -643,7 +645,6 @@ exports.create = function(req, res) { const activation_expires = new Date( new Date().getTime() + 1000 * 3600 * 24 ); - models.user_registration_profile .findOrCreate({ defaults: { @@ -698,7 +699,6 @@ exports.create = function(req, res) { debug('Failed connecting to gravatar: ' + e); }); } - // Send an email to the user const link = config.host + diff --git a/doc/installation_and_administration_guide/configuration.md b/doc/installation_and_administration_guide/configuration.md old mode 100755 new mode 100644 index e6db29b4..2c4802fc --- a/doc/installation_and_administration_guide/configuration.md +++ b/doc/installation_and_administration_guide/configuration.md @@ -21,6 +21,8 @@ specific needs of each use case. These are the main configurations: - External authentication. +- External Authentication with SAML. + - Authorization. - Mail Server. @@ -350,6 +352,58 @@ config.external_auth = { The way to check password validity can be customized in with parameter _external_auth.encryption_. SHA1 and BCrypt are currently supported. +## External Authentication with SAML + +You can also configure the Identity Manager to authenticate users through an +external user in identity provider(idp). + +When using this option, after the user correclty authenticates using his/her +remote credentials, a local copy of the user is created. For authenticating the +user externally Keyrock needs to read a set of user attributes from the SAML +profile. These SAML profile are: + +- username: the display name of the user. + +- email: the email address is the value used for authenticating the user. + +For keycloak configuration(v4.8.3 Final), you create SAML client, and config + +- Valid Redirect URIs to keyrock server. +- Assertion Consumer Service POST Binding URL to keyrock server. +- IDP Initiated SSO URL Name to create SAML entry point (URL). then config + mapper in keycloak for SAML profile. + +An example of this configuration is: + +```javascript +config.external_user_sso = { + enabled: true, + entry_point: 'https://{{keycloak-server}}/auth/realms/smartcity/protocol/saml/clients/keyrock'), + issuer: 'keyrock') +} +``` + +An example of keycloak configuration is: + +**client configuration** + +``` +- Valid Redirect URIs: https://{{keyrock-server}}:3005. +- Assertion Consumer Service POST Binding URL: https://{{keyrock-server}}:3005. +- IDP Initiated SSO URL Name: keyrock. + (You will got Target IDP initiated SSO URL: https://{{keyrock-server}}/auth/realms/smartcity/protocol/saml/clients/keyrock) +``` + +**mapper configuration** + +``` +- username: the display name of the user. + (For Keycloak Mapper, Name: username, Type: User Property, Property: username) + +- email: the email address is the value used for authenticating the user. + (For Keycloak Mapper, Name: email, Type: User Property, Property: email) +``` + ## Authorization Configure Policy Decision Point (PDP) diff --git a/package-lock.json b/package-lock.json index 3fee98f3..854fbe61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "fiware-idm", - "version": "7.8.0", + "version": "7.8.1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -7402,6 +7402,75 @@ "integrity": "sha1-Fv+RrkC6DpLEPmcXY/3IQqcCcLE=", "dev": true }, + "passport": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.4.1.tgz", + "integrity": "sha512-IxXgZZs8d7uFSt3eqNjM9NQ3g3uQCW5avD8mRNoXV99Yig50vjuaez6dQK2qC0kVWPRTujxY0dWgGfT09adjYg==", + "requires": { + "passport-strategy": "1.x.x", + "pause": "0.0.1" + } + }, + "passport-saml": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/passport-saml/-/passport-saml-1.3.2.tgz", + "integrity": "sha512-oRtv1lF0AeOVGPD/UJMJnOO7AIc/Wgw7qfMxgejm2bjBo85a26LQfP+XnOD5gW7fxRdYKXDAIOvqPhFeGJmyBw==", + "requires": { + "debug": "^3.1.0", + "passport-strategy": "*", + "q": "^1.5.0", + "xml-crypto": "^1.4.0", + "xml-encryption": "^1.0.0", + "xml2js": "0.4.x", + "xmlbuilder": "^11.0.0", + "xmldom": "0.1.x" + }, + "dependencies": { + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "xml-crypto": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-1.4.0.tgz", + "integrity": "sha512-K8FRdRxICVulK4WhiTUcJrRyAIJFPVOqxfurA3x/JlmXBTxy+SkEENF6GeRt7p/rB6WSOUS9g0gXNQw5n+407g==", + "requires": { + "xmldom": "0.1.27", + "xpath": "0.0.27" + } + }, + "xml-encryption": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-1.0.0.tgz", + "integrity": "sha512-xTqcgKPN3XOswvDPXrhtyvWZ96IFcO9Azv3vS060kOpBsK5T7OxbQDxb59bPLl4b4c2IgmSZC3kJB0n5WPr2Mw==", + "requires": { + "escape-html": "^1.0.3", + "node-forge": "^0.7.0", + "xmldom": "~0.1.15", + "xpath": "0.0.27" + } + }, + "xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" + } + } + }, + "passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=" + }, "path-dirname": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", @@ -7470,6 +7539,11 @@ "pinkie-promise": "^2.0.0" } }, + "pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=" + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -7836,6 +7910,11 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, + "q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=" + }, "qs": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", diff --git a/package.json b/package.json index 652b4998..ccfb15a4 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,8 @@ "nodemailer-mailgun-transport": "^1.4.0", "nodemailer-smtp-transport": "~2.7.4", "oauth2-server": "git+https://github.com/ging/node-oauth2-server#master", + "passport": "^0.4.1", + "passport-saml": "^1.3.2", "pg": "^7.5.0", "request": "^2.85.0", "sequelize": "^4.22.0", diff --git a/routes/web/authenticate.js b/routes/web/authenticate.js index cb65ec76..f7371ac1 100755 --- a/routes/web/authenticate.js +++ b/routes/web/authenticate.js @@ -3,8 +3,11 @@ const router = express.Router(); const csrf = require('csurf'); const csrf_protection = csrf({ cookie: true }); +const passport = require('passport'); + // Home web Controller const web_session_controller = require('../../controllers/web/index').sessions; +const web_sso_controller = require('../../controllers/web/index').sso; // Routes for users sessions router.get( @@ -26,4 +29,12 @@ router.delete( ); router.delete('/external_logout', web_session_controller.external_destroy); +router.get('/sso/login', passport.authenticate('samlStrategy')); + +router.post( + '/sso/callback', + passport.authenticate('samlStrategy'), + web_sso_controller.load_user_by_email +); + module.exports = router; diff --git a/routes/web/index.js b/routes/web/index.js index 07e320c6..33d6698f 100755 --- a/routes/web/index.js +++ b/routes/web/index.js @@ -5,6 +5,7 @@ const debug = require('debug')('idm:web_index_model'); const router = express.Router(); const csrf_protection = csrf({ cookie: true }); +const config = require('../../config'); // Create controllers const web_session_controller = require('../../controllers/web/index').sessions; @@ -24,7 +25,11 @@ router.get('/', csrf_protection, function(req, res) { if (req.session.user) { res.redirect('/idm'); } else { - res.render('index', { errors: [], csrf_token: req.csrfToken() }); + res.render('index', { + errors: [], + csrf_token: req.csrfToken(), + sso_enabled: config.external_user_sso.enabled, + }); } }); diff --git a/views/auth/_login.ejs b/views/auth/_login.ejs index 97d3c0b1..85a0486a 100755 --- a/views/auth/_login.ejs +++ b/views/auth/_login.ejs @@ -51,6 +51,9 @@ + <% if ((typeof sso_enabled !== 'undefined') && (sso_enabled)) { %> + Sing In with SSO + <% } %>
diff --git a/views/settings/settings.ejs b/views/settings/settings.ejs index e32095ce..fca3863c 100755 --- a/views/settings/settings.ejs +++ b/views/settings/settings.ejs @@ -41,6 +41,7 @@
+ <% if (typeof sso_enabled !== 'undefined' && !sso_enabled) { %>