This commit is contained in:
Iván Eixarch 2020-09-16 08:55:13 +02:00 committed by GitHub
commit d98e2e5b56
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 182 additions and 17 deletions

View file

@ -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) | | LDAP_UIDTAG | LDAP UID tag (e.g. uid/cn/username) |
| WITH_ZONE_MTA | enables or disables builtin Zone-MTA (default: true) | | WITH_ZONE_MTA | enables or disables builtin Zone-MTA (default: true) |
| POOL_NAME | sets builtin Zone-MTA pool name (default: os.hostname()) | | 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`) | | 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. 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.

View file

@ -11,7 +11,7 @@ import {ActionLink, Button, DismissibleAlert, DropdownActionLink, Icon} from "./
import mailtrainConfig from "mailtrainConfig"; import mailtrainConfig from "mailtrainConfig";
import styles from "./styles.scss"; import styles from "./styles.scss";
import {getRoutes, renderRoute, Resolver, SectionContentContext, withPageHelpers} from "./page-common"; 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 {createComponentMixin, withComponentMixins} from "./decorator-helpers";
import {getLang} from "../../../shared/langs"; import {getLang} from "../../../shared/langs";
@ -414,6 +414,12 @@ export class SectionContent extends Component {
} }
componentDidMount() { 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); 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?'); 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() { ensureAuthenticated() {
if (!mailtrainConfig.isAuthenticated) { 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 { export class DropdownLink extends Component {
static propTypes = { static propTypes = {
to: PropTypes.string, to: PropTypes.string,
className: PropTypes.string className: PropTypes.string,
forceReload: PropTypes.bool
} }
render() { render() {
const props = this.props; const props = this.props;
const clsName = "dropdown-item" + (props.className ? " " + props.className : "") const clsName = "dropdown-item" + (props.className ? " " + props.className : "")
return ( if (props.forceReload) {
return (
<Link to={props.to} className={clsName} onClick={() => window.location.href=props.to}>{props.children}</Link>
);
} else {
return (
<Link to={props.to} className={clsName}>{props.children}</Link> <Link to={props.to} className={clsName}>{props.children}</Link>
); );
}
} }
} }

View file

@ -110,8 +110,8 @@ export default class Login extends Component {
} else if (mailtrainConfig.externalPasswordResetLink) { } else if (mailtrainConfig.externalPasswordResetLink) {
passwordResetLink = <a href={mailtrainConfig.externalPasswordResetLink}>{t('forgotYourPassword?')}</a>; passwordResetLink = <a href={mailtrainConfig.externalPasswordResetLink}>{t('forgotYourPassword?')}</a>;
} }
if (mailtrainConfig.authMethod != 'cas') {
return ( return (
<div> <div>
<Title>{t('signIn')}</Title> <Title>{t('signIn')}</Title>
@ -126,6 +126,15 @@ export default class Login extends Component {
</ButtonRow> </ButtonRow>
</Form> </Form>
</div> </div>
); );
} else {
return (
<div>
<Title>{t('signIn')} CAS</Title>
{<a href="/cas/login" class="btn btn-primary">{t('signIn')}</a>}
{passwordResetLink}
</div>
);
}
} }
} }

View file

@ -96,7 +96,8 @@ class Root extends Component {
{getLanguageChooser(t)} {getLanguageChooser(t)}
<NavDropdown menuClassName="dropdown-menu-right" label={mailtrainConfig.user.username} icon="user"> <NavDropdown menuClassName="dropdown-menu-right" label={mailtrainConfig.user.username} icon="user">
<DropdownLink to="/account"><Icon icon='user'/> {t('account')}</DropdownLink> <DropdownLink to="/account"><Icon icon='user'/> {t('account')}</DropdownLink>
<DropdownActionLink onClickAsync={::this.logout}><Icon icon='sign-out-alt'/> {t('logOut')}</DropdownActionLink> {mailtrainConfig.authMethod == 'cas' && <DropdownLink to="/cas/logout" forceReload><Icon icon="sign-out-alt"/> {t('logOut')}</DropdownLink>}
{mailtrainConfig.authMethod != 'cas' && <DropdownActionLink onClickAsync={::this.logout}><Icon icon='sign-out-alt'/> {t('logOut')}</DropdownActionLink>}
</NavDropdown> </NavDropdown>
</ul> </ul>
</> </>

View file

@ -26,6 +26,12 @@ LDAP_UIDTAG=${LDAP_UIDTAG:-'username'}
LDAP_MAILTAG=${LDAP_MAILTAG:-'mail'} LDAP_MAILTAG=${LDAP_MAILTAG:-'mail'}
LDAP_NAMETAG=${LDAP_NAMETAG:-'username'} LDAP_NAMETAG=${LDAP_NAMETAG:-'username'}
LDAP_METHOD=${LDAP_METHOD:-'ldapjs'} 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'} MONGO_HOST=${MONGO_HOST:-'mongo'}
WITH_REDIS=${WITH_REDIS:-'true'} WITH_REDIS=${WITH_REDIS:-'true'}
REDIS_HOST=${REDIS_HOST:-'redis'} REDIS_HOST=${REDIS_HOST:-'redis'}
@ -114,6 +120,27 @@ ldap:
EOT EOT
fi fi
# Manage CAS if enabled
if [ "$WITH_CAS" = "true" ]; then
echo 'Info: CAS enabled'
cat >> server/config/production.yaml <<EOT
cas:
enabled: true
url: $CAS_URL
nameTag: $CAS_NAMETAG
mailTag: $CAS_MAILTAG
newUserRole: $CAS_NEWUSERROLE
newUserNamespaceId: $CAS_NEWUSERNAMESPACEID
EOT
else
echo 'Info: CAS not enabled'
cat >> server/config/production.yaml <<EOT
cas:
enabled: false
EOT
fi
fi fi
if [ -f server/services/workers/reports/config/production.yaml ]; then if [ -f server/services/workers/reports/config/production.yaml ]; then
@ -143,4 +170,9 @@ if [ "$WITH_ZONE_MTA" = "true" ]; then
fi fi
cd server cd server
# Install passport-cas2 node package if CAS selected
if [ "$WITH_CAS" = "true" ]; then
echo 'Info: Installing passport-cas2'
NODE_ENV=production npm install passport-cas2
fi
NODE_ENV=production node index.js NODE_ENV=production node index.js

View file

@ -1065,5 +1065,8 @@
"channelName": "Channel \"{{name}}\"", "channelName": "Channel \"{{name}}\"",
"cloneCampaign": "Clone Campaign", "cloneCampaign": "Clone Campaign",
"next": "Next", "next": "Next",
"selectCampaignToBeCloned": "Select campaign to be cloned." "selectCampaignToBeCloned": "Select campaign to be cloned.",
"authenticationSuccessful": "Authentication successful",
"logoutSuccessful": "Logout successful",
"authenticationFailed": "Authentication failed"
} }

View file

@ -1071,5 +1071,8 @@
"channelName": "Channel \"{{name}}\"", "channelName": "Channel \"{{name}}\"",
"cloneCampaign": "Clone Campaign", "cloneCampaign": "Clone Campaign",
"next": "Next", "next": "Next",
"selectCampaignToBeCloned": "Select campaign to be cloned." "selectCampaignToBeCloned": "Select campaign to be cloned.",
"authenticationSuccessful": "Authentication successful",
"logoutSuccessful": "Logout successful",
"authenticationFailed": "Authentication failed"
} }

View file

@ -1095,5 +1095,8 @@
"selectCampaignToBeCloned": "Elige la campaña que será clonada.", "selectCampaignToBeCloned": "Elige la campaña que será clonada.",
"tagLanguage": "Lenguaje de marcado", "tagLanguage": "Lenguaje de marcado",
"tagLanguageMustBeSelected": "Debes seleccionar un lenguaje de marcado", "tagLanguageMustBeSelected": "Debes seleccionar un lenguaje de marcado",
"helpText": "Texto de ayuda" "helpText": "Texto de ayuda",
"authenticationSuccessful": "Autentificación completada con éxito",
"logoutSuccessful": "Logout completado con éxito",
"authenticationFailed": "Autentificación fallida"
} }

View file

@ -1066,5 +1066,8 @@
"channelName": "Channel \"{{name}}\"", "channelName": "Channel \"{{name}}\"",
"cloneCampaign": "Clone Campaign", "cloneCampaign": "Clone Campaign",
"next": "Next", "next": "Next",
"selectCampaignToBeCloned": "Select campaign to be cloned." "selectCampaignToBeCloned": "Select campaign to be cloned.",
"authenticationSuccessful": "Authentication successful",
"logoutSuccessful": "Logout successful",
"authenticationFailed": "Authentication failed"
} }

View file

@ -1144,5 +1144,8 @@
"channelName": "Channel \"{{name}}\"", "channelName": "Channel \"{{name}}\"",
"cloneCampaign": "Clone Campaign", "cloneCampaign": "Clone Campaign",
"next": "Next", "next": "Next",
"selectCampaignToBeCloned": "Select campaign to be cloned." "selectCampaignToBeCloned": "Select campaign to be cloned.",
"authenticationSuccessful": "Authentication successful",
"logoutSuccessful": "Logout successful",
"authenticationFailed": "Authentication failed"
} }

View file

@ -324,6 +324,14 @@ async function createApp(appType) {
app.use('/rest', reportsRest); app.use('/rest', reportsRest);
} }
install404Fallback('/rest'); install404Fallback('/rest');
if (config.cas && config.cas.enabled === true) {
app.get('/cas/login',
passport.authenticateCas,
function(req, res) {
res.redirect('/?cas-login-success');
});
app.get('/cas/logout', passport.logoutCas);
}
} }
app.use('/', await index.getRouter(appType)); app.use('/', await index.getRouter(appType));

View file

@ -171,6 +171,18 @@ postfixBounce:
# allow connections from localhost only # allow connections from localhost only
host: 127.0.0.1 host: 127.0.0.1
cas:
# enable to use CAS user backend
enabled: false
url: https://example.cas-server.com
# nameTag identifies the attribute to be used for user's full name
nameTag: username
# mailTag identifies the attribute to be used for user's email address
mailTag: mail
newUserRole: nobody
# Global namespace id
newUserNamespaceId: 1
# extra options for nodemailer # extra options for nodemailer
nodemailer: nodemailer:
#textEncoding: base64 #textEncoding: base64

View file

@ -175,8 +175,73 @@ module.exports.restLogin = (req, res, next) => {
}); });
})(req, res, next); })(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 + ')'); log.info('Using LDAP auth (passport-' + authMode === 'ldap' ? 'ldapjs' : authMode + ')');
module.exports.authMethod = 'ldap'; module.exports.authMethod = 'ldap';
module.exports.isAuthMethodLocal = false; module.exports.isAuthMethodLocal = false;

View file

@ -27,7 +27,7 @@ const namespaceHelpers = require('../lib/namespace-helpers');
const allowedKeys = new Set(['username', 'name', 'email', 'password', 'namespace', 'role']); const allowedKeys = new Set(['username', 'name', 'email', 'password', 'namespace', 'role']);
const ownAccountAllowedKeys = new Set(['name', 'email', 'password']); 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 hashKeys = new Set(['username', 'name', 'email', 'namespace', 'role']);
const shares = require('./shares'); const shares = require('./shares');
const contextHelpers = require('../lib/context-helpers'); const contextHelpers = require('../lib/context-helpers');