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 (
+
+ );
+ }
}
}
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');