diff --git a/README.md b/README.md index a427fb6e..973ff4a7 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,12 @@ variables (e.g. `URL_BASE_TRUSTED=https://mailtrain.domain.com (and more env-var | LDAP_UIDTAG | LDAP UID tag (e.g. uid/cn/username) | | WITH_ZONE_MTA | enables or disables builtin Zone-MTA (default: true) | | POOL_NAME | sets builtin Zone-MTA pool name (default: os.hostname()) | +| WITH_CAS | use if you want to use CAS | +| CAS_URL | CAS base URL | +| CAS_NAMETAG | The field used to save the name (default: username) | +| CAS_MAILTAG | The field used to save the email (default: mail) | +| CAS_NEWUSERROLE | The role of new users (default: nobody) | +| CAS_NEWUSERNAMESPACEID | The namespace id of new users (default: 1) | | LOG_LEVEL | sets log level among `silly|verbose|info|http|warn|error|silent` (default: `info`) | If you don't want to modify the original `docker-compose.yml`, you can put your overrides to another file (e.g. `docker-compose.override.yml`) -- like the one below. diff --git a/client/src/lib/page.js b/client/src/lib/page.js index d5222617..94d8505f 100644 --- a/client/src/lib/page.js +++ b/client/src/lib/page.js @@ -11,7 +11,7 @@ import {ActionLink, Button, DismissibleAlert, DropdownActionLink, Icon} from "./ import mailtrainConfig from "mailtrainConfig"; import styles from "./styles.scss"; import {getRoutes, renderRoute, Resolver, SectionContentContext, withPageHelpers} from "./page-common"; -import {getBaseDir} from "./urls"; +import {getBaseDir, getUrl} from "./urls"; import {createComponentMixin, withComponentMixins} from "./decorator-helpers"; import {getLang} from "../../../shared/langs"; @@ -414,6 +414,12 @@ export class SectionContent extends Component { } componentDidMount() { + const t = this.props.t; + const queryParams = this.props.location.search; + if (queryParams.indexOf('cas-login-success') > -1) this.setFlashMessage('success', t('authenticationSuccessful')); + if (queryParams.indexOf('cas-logout-success') > -1) this.setFlashMessage('success', t('logoutSuccessful')); + if (queryParams.indexOf('cas-login-error') > -1) this.setFlashMessage('danger', t('authenticationFailed')); + window.addEventListener('beforeunload', this.beforeUnloadHandler); this.historyUnblock = this.props.history.block('Changes you made may not be saved. Are you sure you want to leave this page?'); } @@ -445,7 +451,11 @@ export class SectionContent extends Component { ensureAuthenticated() { if (!mailtrainConfig.isAuthenticated) { - this.navigateTo('/login?next=' + encodeURIComponent(window.location.pathname)); + if (mailtrainConfig.authMethod == 'cas') { + window.location.href=getUrl('cas/login?next=' + encodeURIComponent(window.location.pathname)); + } else { + this.navigateTo('/login?next=' + encodeURIComponent(window.location.pathname)); + } } } @@ -594,16 +604,23 @@ export class LinkButton extends Component { export class DropdownLink extends Component { static propTypes = { to: PropTypes.string, - className: PropTypes.string + className: PropTypes.string, + forceReload: PropTypes.bool } render() { const props = this.props; const clsName = "dropdown-item" + (props.className ? " " + props.className : "") - return ( + if (props.forceReload) { + return ( + window.location.href=props.to}>{props.children} + ); + } else { + return ( {props.children} - ); + ); + } } } @@ -729,4 +746,4 @@ export function getLanguageChooser(t) { ); return languageChooser; -} \ No newline at end of file +} diff --git a/client/src/login/Login.js b/client/src/login/Login.js index 1942b639..8559467f 100644 --- a/client/src/login/Login.js +++ b/client/src/login/Login.js @@ -110,8 +110,8 @@ export default class Login extends Component { } else if (mailtrainConfig.externalPasswordResetLink) { passwordResetLink = {t('forgotYourPassword?')}; } - - return ( + if (mailtrainConfig.authMethod != 'cas') { + return (
{t('signIn')} @@ -126,6 +126,15 @@ export default class Login extends Component {
- ); + ); + } else { + return ( +
+ {t('signIn')} CAS + {{t('signIn')}} + {passwordResetLink} +
+ ); + } } } diff --git a/client/src/root.js b/client/src/root.js index f6e3dfea..128a53bf 100644 --- a/client/src/root.js +++ b/client/src/root.js @@ -96,7 +96,8 @@ class Root extends Component { {getLanguageChooser(t)} {t('account')} - {t('logOut')} + {mailtrainConfig.authMethod == 'cas' && {t('logOut')}} + {mailtrainConfig.authMethod != 'cas' && {t('logOut')}} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 0298c569..3d14492e 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -26,6 +26,12 @@ LDAP_UIDTAG=${LDAP_UIDTAG:-'username'} LDAP_MAILTAG=${LDAP_MAILTAG:-'mail'} LDAP_NAMETAG=${LDAP_NAMETAG:-'username'} LDAP_METHOD=${LDAP_METHOD:-'ldapjs'} +WITH_CAS=${WITH_CAS:-'false'} +CAS_URL=${CAS_URL:-'https://example.cas-server.com'} +CAS_NAMETAG=${CAS_NAMETAG:-'username'} +CAS_MAILTAG=${CAS_MAILTAG:-'mail'} +CAS_NEWUSERROLE=${CAS_NEWUSERROLE:-'nobody'} +CAS_NEWUSERNAMESPACEID=${CAS_NEWUSERNAMESPACEID:-'1'} MONGO_HOST=${MONGO_HOST:-'mongo'} WITH_REDIS=${WITH_REDIS:-'true'} REDIS_HOST=${REDIS_HOST:-'redis'} @@ -114,6 +120,27 @@ ldap: EOT fi + # Manage CAS if enabled + if [ "$WITH_CAS" = "true" ]; then + echo 'Info: CAS enabled' + cat >> server/config/production.yaml <> server/config/production.yaml < { }); })(req, res, next); }; +let CasStrategy; +if (config.cas && config.cas.enabled === true) { + try { + CasStrategy = require('passport-cas2').Strategy; + authMode = 'cas'; + log.info('CAS', 'Found module "passport-cas2". It will be used for CAS auth.'); + } catch (exc) { + log.info('CAS', 'Module passport-cas2 not installed.'); + } +} +if (CasStrategy) { + log.info('Using CAS auth (passport-cas2)'); + module.exports.authMethod = 'cas'; + module.exports.isAuthMethodLocal = false; -if (LdapStrategy) { + const cas = new CasStrategy({ + casURL: config.cas.url, + propertyMap: { + displayName: config.cas.nameTag, + emails: config.cas.mailTag + } + }, + nodeifyFunction(async (username, profile) => { + try { + const user = await users.getByUsername(username); + + log.info('CAS', 'Old User: '+JSON.stringify(profile)); + return { + id: user.id, + username: username, + name: profile.displayName, + email: profile.emails[0].value, + role: user.role + }; + } catch (err) { + if (err instanceof interoperableErrors.NotFoundError) { + const userId = await users.create(contextHelpers.getAdminContext(), { + username: username, + role: config.cas.newUserRole, + namespace: config.cas.newUserNamespaceId, + name: profile.displayName, + email: profile.emails[0].value + }); + log.info('CAS', 'New User: '+JSON.stringify(profile)); + + return { + id: userId, + username: username, + name: profile.displayName, + email: profile.emails[0].value, + role: config.cas.newUserRole + }; + } else { + throw err; + } + } + })); + passport.use(cas); + passport.serializeUser((user, done) => done(null, user)); + passport.deserializeUser((user, done) => done(null, user)); + + module.exports.authenticateCas = passport.authenticate('cas', { failureRedirect: '/login?cas-login-error' }); + module.exports.logoutCas = function (req, res) { + cas.logout(req, res, config.www.trustedUrlBase+'/?cas-logout-success'); + }; + +} else if (LdapStrategy) { log.info('Using LDAP auth (passport-' + authMode === 'ldap' ? 'ldapjs' : authMode + ')'); module.exports.authMethod = 'ldap'; module.exports.isAuthMethodLocal = false; diff --git a/server/models/users.js b/server/models/users.js index 6ce7aee2..13482a36 100644 --- a/server/models/users.js +++ b/server/models/users.js @@ -27,7 +27,7 @@ const namespaceHelpers = require('../lib/namespace-helpers'); const allowedKeys = new Set(['username', 'name', 'email', 'password', 'namespace', 'role']); const ownAccountAllowedKeys = new Set(['name', 'email', 'password']); -const allowedKeysExternal = new Set(['username', 'namespace', 'role']); +const allowedKeysExternal = new Set(['username', 'namespace', 'role', 'name', 'email']); const hashKeys = new Set(['username', 'name', 'email', 'namespace', 'role']); const shares = require('./shares'); const contextHelpers = require('../lib/context-helpers');