Commit bd3b6473 authored by enahum's avatar enahum Committed by GitHub
Browse files

SAML support (#3494)

* PLT-3073: Implement SAML/Okta Server side (EE) (#3422)

* PLT-3137 Support for SAML configuration

* PLT-3410 SAML Database Store

* PLT-3411 CLI to add Identity Provider Certificate and Service Provider Private Key

* PLT-3409 SAML Interface for EE

* PLT-3139 Handle SAML authentication server side

* Add localization messages

* PLT-3443 SAML Obtain SP metadata

* PLT-3142 Login & Switch to/from SAML

* Remove Certs for Database & Clean SAML Request

* Make required Username, FirstName and LastName

* PLT-3140 Add SAML to System Console (#3476)

* PLT-3140 Add SAML to System Console

* Move web_client functions to client.jsx

* Fix issues found by PM

* update package.json mattermost driver

* Fix text messages for SAML
parent c1953d9a
......@@ -176,6 +176,7 @@ export default class AdminSidebar extends React.Component {
render() {
let ldapSettings = null;
let samlSettings = null;
let complianceSettings = null;
let license = null;
......@@ -198,6 +199,20 @@ export default class AdminSidebar extends React.Component {
);
}
if (global.window.mm_license.SAML === 'true') {
samlSettings = (
<AdminSidebarSection
name='saml'
title={
<FormattedMessage
id='admin.sidebar.saml'
defaultMessage='SAML'
/>
}
/>
);
}
if (global.window.mm_license.Compliance === 'true') {
complianceSettings = (
<AdminSidebarSection
......@@ -391,6 +406,7 @@ export default class AdminSidebar extends React.Component {
}
/>
{ldapSettings}
{samlSettings}
</AdminSidebarSection>
<AdminSidebarSection
name='security'
......
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import $ from 'jquery';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import Setting from './setting.jsx';
import * as Utils from 'utils/utils.jsx';
export default class FileUploadSetting extends Setting {
static get propTypes() {
return {
id: React.PropTypes.string.isRequired,
label: React.PropTypes.node.isRequired,
helpText: React.PropTypes.node,
uploadingText: React.PropTypes.node,
onSubmit: React.PropTypes.func.isRequired,
disabled: React.PropTypes.bool,
fileType: React.PropTypes.string.isRequired
};
}
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.state = {
fileName: null
};
}
handleChange() {
const files = this.refs.fileInput.files;
if (files && files.length > 0) {
this.setState({fileSelected: true, fileName: files[0].name});
}
}
handleSubmit(e) {
e.preventDefault();
$(this.refs.upload_button).button('loading');
this.props.onSubmit(this.props.id, this.refs.fileInput.files[0], (error) => {
$(this.refs.upload_button).button('reset');
if (error) {
Utils.clearFileInput(this.refs.fileInput);
}
this.setState({fileSelected: false, fileName: null, serverError: error});
});
}
render() {
let serverError;
if (this.state.serverError) {
serverError = <div className='form-group has-error'><label className='control-label'>{this.state.serverError}</label></div>;
}
var btnClass = 'btn';
if (this.state.fileSelected) {
btnClass = 'btn btn-primary';
}
let fileName;
if (this.state.fileName) {
fileName = this.state.fileName;
} else {
fileName = (
<FormattedMessage
id='admin.file_upload.noFile'
defaultMessage='No file uploaded'
/>
);
}
return (
<Setting
label={this.props.label}
helpText={this.props.helpText}
inputId={this.props.id}
>
<div>
<div className='file__upload'>
<button
className='btn btn-default'
disabled={this.props.disabled}
>
<FormattedMessage
id='admin.file_upload.chooseFile'
defaultMessage='Choose File'
/>
</button>
<input
ref='fileInput'
type='file'
disabled={this.props.disabled}
accept={this.props.fileType}
onChange={this.handleChange}
/>
</div>
<button
className={btnClass}
disabled={!this.state.fileSelected}
onClick={this.handleSubmit}
ref='upload_button'
data-loading-text={`<span class=\'glyphicon glyphicon-refresh glyphicon-refresh-animate\'></span> ${this.props.uploadingText}`}
>
<FormattedMessage
id='admin.file_upload.uploadFile'
defaultMessage='Upload'
/>
</button>
<div className='help-text no-margin'>
{fileName}
</div>
{serverError}
</div>
</Setting>
);
}
}
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import $ from 'jquery';
import React from 'react';
import Setting from './setting.jsx';
export default class RemoveFileSetting extends Setting {
static get propTypes() {
return {
id: React.PropTypes.string.isRequired,
label: React.PropTypes.node.isRequired,
helpText: React.PropTypes.node,
removeButtonText: React.PropTypes.node.isRequired,
removingText: React.PropTypes.node,
fileName: React.PropTypes.string.isRequired,
onSubmit: React.PropTypes.func.isRequired,
disabled: React.PropTypes.bool
};
}
constructor(props) {
super(props);
this.handleRemove = this.handleRemove.bind(this);
this.state = {
serverError: null
};
}
handleRemove(e) {
e.preventDefault();
$(this.refs.remove_button).button('loading');
this.props.onSubmit(this.props.id, (error) => {
$(this.refs.remove_button).button('reset');
this.setState({serverError: error});
});
}
render() {
let serverError;
if (this.state.serverError) {
serverError = <div className='form-group has-error'><label className='control-label'>{this.state.serverError}</label></div>;
}
return (
<Setting
label={this.props.label}
helpText={this.props.helpText}
inputId={this.props.id}
>
<div>
<div className='help-text remove-filename'>
{this.props.fileName}
</div>
<button
className='btn btn-danger'
onClick={this.handleRemove}
ref='remove_button'
disabled={this.props.disabled}
data-loading-text={`<span class='glyphicon glyphicon-refresh glyphicon-refresh-animate'></span> ${this.props.removingText}`}
>
{this.props.removeButtonText}
</button>
{serverError}
</div>
</Setting>
);
}
}
\ No newline at end of file
This diff is collapsed.
......@@ -2,6 +2,7 @@
// See License.txt for license information.
import Client from 'utils/web_client.jsx';
import Constants from 'utils/constants.jsx';
import * as Utils from 'utils/utils.jsx';
import UserStore from 'stores/user_store.jsx';
import ConfirmModal from '../confirm_modal.jsx';
......@@ -374,12 +375,13 @@ export default class UserItem extends React.Component {
let authServiceText;
let passwordReset;
if (user.auth_service) {
const service = (user.auth_service === Constants.LDAP_SERVICE || user.auth_service === Constants.SAML_SERVICE) ? user.auth_service.toUpperCase() : Utils.toTitleCase(user.auth_service);
authServiceText = (
<FormattedHTMLMessage
id='admin.user_item.authServiceNotEmail'
defaultMessage=', <strong>Sign-in Method:</strong> {service}'
values={{
service: Utils.toTitleCase(user.auth_service)
service
}}
/>
);
......
......@@ -3,6 +3,7 @@
import * as Utils from 'utils/utils.jsx';
import Client from 'utils/web_client.jsx';
import Constants from 'utils/constants.jsx';
import React from 'react';
import ReactDOM from 'react-dom';
......@@ -55,7 +56,8 @@ export default class EmailToOAuth extends React.Component {
formClass += ' has-error';
}
const uiType = Utils.toTitleCase(this.props.newType) + ' SSO';
const type = (this.props.newType === Constants.SAML_SERVICE ? Constants.SAML_SERVICE.toUpperCase() : Utils.toTitleCase(this.props.newType));
const uiType = `${type} SSO`;
return (
<div>
......@@ -74,7 +76,7 @@ export default class EmailToOAuth extends React.Component {
id='claim.email_to_oauth.ssoType'
defaultMessage='Upon claiming your account, you will only be able to login with {type} SSO'
values={{
type: Utils.toTitleCase(this.props.newType)
type
}}
/>
</p>
......@@ -83,7 +85,7 @@ export default class EmailToOAuth extends React.Component {
id='claim.email_to_oauth.ssoNote'
defaultMessage='You must already have a valid {type} account'
values={{
type: Utils.toTitleCase(this.props.newType)
type
}}
/>
</p>
......
......@@ -3,6 +3,7 @@
import * as Utils from 'utils/utils.jsx';
import Client from 'utils/web_client.jsx';
import Constants from 'utils/constants.jsx';
import React from 'react';
import ReactDOM from 'react-dom';
......@@ -62,7 +63,7 @@ export default class OAuthToEmail extends React.Component {
formClass += ' has-error';
}
const uiType = Utils.toTitleCase(this.props.currentType) + ' SSO';
const uiType = `${(this.props.currentType === Constants.SAML_SERVICE ? Constants.SAML_SERVICE.toUpperCase() : Utils.toTitleCase(this.props.currentType))} SSO`;
return (
<div>
......@@ -85,7 +86,7 @@ export default class OAuthToEmail extends React.Component {
<p>
<FormattedMessage
id='claim.oauth_to_email.enterNewPwd'
defaultMessage='Enter a new password for your {site} account'
defaultMessage='Enter a new password for your {site} email account'
values={{
site: global.window.mm_config.SiteName
}}
......
......@@ -43,6 +43,7 @@ export default class LoginController extends React.Component {
ldapEnabled: global.window.mm_license.IsLicensed === 'true' && global.window.mm_config.EnableLdap === 'true',
usernameSigninEnabled: global.window.mm_config.EnableSignInWithUsername === 'true',
emailSigninEnabled: global.window.mm_config.EnableSignInWithEmail === 'true',
samlEnabled: global.window.mm_license.IsLicensed === 'true' && global.window.mm_config.EnableSaml === 'true',
loginId: '', // the browser will set a default for this
password: '',
showMfa: false
......@@ -319,6 +320,7 @@ export default class LoginController extends React.Component {
const ldapEnabled = this.state.ldapEnabled;
const gitlabSigninEnabled = global.window.mm_config.EnableSignUpWithGitLab === 'true';
const googleSigninEnabled = global.window.mm_config.EnableSignUpWithGoogle === 'true';
const samlSigninEnabled = this.state.samlEnabled;
const usernameSigninEnabled = this.state.usernameSigninEnabled;
const emailSigninEnabled = this.state.emailSigninEnabled;
......@@ -416,7 +418,7 @@ export default class LoginController extends React.Component {
);
}
if ((emailSigninEnabled || usernameSigninEnabled || ldapEnabled) && (gitlabSigninEnabled || googleSigninEnabled)) {
if ((emailSigninEnabled || usernameSigninEnabled || ldapEnabled) && (gitlabSigninEnabled || googleSigninEnabled || samlSigninEnabled)) {
loginControls.push(
<div
key='divider'
......@@ -475,6 +477,20 @@ export default class LoginController extends React.Component {
);
}
if (samlSigninEnabled) {
loginControls.push(
<a
className='btn btn-custom-login saml'
key='gitlab'
href={'/login/sso/saml' + this.props.location.search}
>
<span>
{window.mm_config.SamlLoginButtonText}
</span>
</a>
);
}
return (
<div>
{extraBox}
......
......@@ -588,6 +588,20 @@ export default class SignupUserComplete extends React.Component {
);
}
if (global.window.mm_config.EnableSaml === 'true' && global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.SAML === 'true') {
signupMessage.push(
<a
className='btn btn-custom-login saml'
key='saml'
href={`/login/sso/saml${window.location.search}${window.location.search ? '&' : '?'}action=signup`}
>
<span>
{global.window.mm_config.SamlLoginButtonText}
</span>
</a>
);
}
let ldapSignup;
if (global.window.mm_config.EnableLdap === 'true' && global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.LDAP) {
ldapSignup = (
......
......@@ -412,6 +412,24 @@ class UserSettingsGeneralTab extends React.Component {
{helpText}
</div>
);
} else if (this.props.user.auth_service === Constants.SAML_SERVICE) {
inputs.push(
<div
key='oauthEmailInfo'
className='form-group'
>
<div className='setting-list__hint'>
<FormattedMessage
id='user.settings.general.emailSamlCantUpdate'
defaultMessage='Login occurs through SAML. Email cannot be updated. Email address used for notifications is {email}.'
values={{
email: this.state.email
}}
/>
</div>
{helpText}
</div>
);
}
emailSection = (
......@@ -478,6 +496,16 @@ class UserSettingsGeneralTab extends React.Component {
}}
/>
);
} else if (this.props.user.auth_service === Constants.SAML_SERVICE) {
describe = (
<FormattedMessage
id='user.settings.general.loginSaml'
defaultMessage='Login done through SAML ({email})'
values={{
email: this.state.email
}}
/>
);
}
emailSection = (
......
......@@ -620,6 +620,24 @@ class SecurityTab extends React.Component {
);
}
let samlOption;
if (global.window.mm_config.EnableSaml === 'true' && user.auth_service === '') {
samlOption = (
<div>
<Link
className='btn btn-primary'
to={'/claim/email_to_oauth?email=' + encodeURIComponent(user.email) + '&old_type=' + user.auth_service + '&new_type=' + Constants.SAML_SERVICE}
>
<FormattedMessage
id='user.settings.security.switchSaml'
defaultMessage='Switch to using SAML SSO'
/>
</Link>
<br/>
</div>
);
}
const inputs = [];
inputs.push(
<div key='userSignInOption'>
......@@ -627,6 +645,7 @@ class SecurityTab extends React.Component {
{gitlabOption}
<br/>
{ldapOption}
{samlOption}
{googleOption}
</div>
);
......@@ -681,6 +700,13 @@ class SecurityTab extends React.Component {
defaultMessage='LDAP'
/>
);
} else if (this.props.user.auth_service === Constants.SAML_SERVICE) {
describe = (
<FormattedMessage
id='user.settings.security.saml'
defaultMessage='SAML'
/>
);
}
return (
......@@ -701,6 +727,7 @@ class SecurityTab extends React.Component {
numMethods = global.window.mm_config.EnableSignUpWithGitLab === 'true' ? numMethods + 1 : numMethods;
numMethods = global.window.mm_config.EnableSignUpWithGoogle === 'true' ? numMethods + 1 : numMethods;
numMethods = global.window.mm_config.EnableLdap === 'true' ? numMethods + 1 : numMethods;
numMethods = global.window.mm_config.EnableSaml === 'true' ? numMethods + 1 : numMethods;
let signInSection;
if (global.window.mm_config.EnableSignUpWithEmail === 'true' && numMethods > 0) {
......
......@@ -102,6 +102,7 @@
"admin.audits.title": "User Activity Logs",
"admin.authentication.email": "Email Auth",
"admin.authentication.gitlab": "GitLab",
"admin.authentication.saml": "SAML",
"admin.banner.heading": "Note:",
"admin.compliance.directoryDescription": "Directory to which compliance reports are written. If blank, will be set to ./data/.",
"admin.compliance.directoryExample": "Ex \"./data/\"",
......@@ -217,6 +218,9 @@
"admin.email.smtpUsernameTitle": "SMTP Server Username:",
"admin.email.testing": "Testing...",
"admin.false": "false",
"admin.file_upload.chooseFile": "Choose File",
"admin.file_upload.noFile": "No file uploaded",
"admin.file_upload.uploadFile": "Upload",
"admin.files.images": "Images",
"admin.files.storage": "Storage",
"admin.general.configuration": "Configuration",
......@@ -431,6 +435,58 @@
"admin.reset_password.submit": "Please enter at least {chars} characters.",
"admin.reset_password.titleReset": "Reset Password",
"admin.reset_password.titleSwitch": "Switch Account to Email/Password",
"admin.saml.assertionConsumerServiceURLDesc": "Enter https://<your-mattermost-url>/login/sso/saml. Make sure you use HTTP or HTTPS in your URL depending on your server configuration. This field is also known as the Assertion Consumer Service URL.",
"admin.saml.assertionConsumerServiceURLEx": "Ex \"https://<your-mattermost-url>/login/sso/saml\"",
"admin.saml.assertionConsumerServiceURLTitle": "Service Provider Login URL:",
"admin.saml.emailAttrDesc": "The attribute in the SAML Assertion that will be used to populate the email addresses of users in Mattermost.",
"admin.saml.emailAttrEx": "Ex \"Email\" or \"PrimaryEmail\"",
"admin.saml.emailAttrTitle": "Email Attribute:",
"admin.saml.enableDescription": "When true, Mattermost allows login using SAML. Please see <a href='http://docs.mattermost.com/deployment/sso-saml.html' target='_blank'>documentation</a> to learn more about configuring SAML for Mattermost.",
"admin.saml.enableTitle": "Enable Login With SAML:",
"admin.saml.encryptDescription": "When true, Mattermost will decrypt SAML Assertions encrypted with your Service Provider Public Certificate.",
"admin.saml.encryptTitle": "Enable Encryption:",
"admin.saml.firstnameAttrDesc": "The attribute in the SAML Assertion that will be used to populate the first name of users in Mattermost.",
"admin.saml.firstnameAttrEx": "Ex \"FirstName\"",
"admin.saml.firstnameAttrTitle": "First Name Attribute:",
"admin.saml.idpCertificateFileDesc": "The public authentication certificate issued by your Identity Provider.",
"admin.saml.idpCertificateFileRemoveDesc": "Remove the public authentication certificate issued by your Identity Provider.",
"admin.saml.idpCertificateFileTitle": "Identity Provider Public Certificate:",
"admin.saml.idpDescriptorUrlDesc": "The issuer URL for the Identity Provider you use for SAML requests.",
"admin.saml.idpDescriptorUrlEx": "Ex \"https://idp.example.org/SAML2/issuer\"",
"admin.saml.idpDescriptorUrlTitle": "Identity Provider Issuer URL:",
"admin.saml.idpUrlDesc": "The URL where Mattermost sends a SAML request to start login sequence.",
"admin.saml.idpUrlEx": "Ex \"https://idp.example.org/SAML2/SSO/Login\"",
"admin.saml.idpUrlTitle": "SAML SSO URL:",
"admin.saml.lastnameAttrDesc": "The attribute in the SAML Assertion that will be used to populate the last name of users in Mattermost.",
"admin.saml.lastnameAttrEx": "Ex \"LastName\"",
"admin.saml.lastnameAttrTitle": "Last Name Attribute:",
"admin.saml.localeAttrDesc": "(Optional) The attribute in the SAML Assertion that will be used to populate the language of users in Mattermost.",
"admin.saml.localeAttrEx": "Ex \"Locale\" or \"PrimaryLanguage\"",
"admin.saml.localeAttrTitle": "Preferred Language Attribute:",
"admin.saml.loginButtonTextDesc": "(Optional) The text that appears in the login button on the login page. Defaults to \"With SAML\".",
"admin.saml.loginButtonTextEx": "Ex \"With OKTA\"",
"admin.saml.loginButtonTextTitle": "Login Button Text:",
"admin.saml.nicknameAttrDesc": "(Optional) The attribute in the SAML Assertion that will be used to populate the nickname of users in Mattermost.",
"admin.saml.nicknameAttrEx": "Ex \"Nickname\"",
"admin.saml.nicknameAttrTitle": "Nickname Attribute:",
"admin.saml.privateKeyFileFileDesc": "The private key used to decrypt SAML Assertions from the Identity Provider.",
"admin.saml.privateKeyFileFileRemoveDesc": "Remove the private key used to decrypt SAML Assertions from the Identity Provider.",
"admin.saml.privateKeyFileTitle": "Service Provider Private Key:",
"admin.saml.publicCertificateFileDesc": "The certificate used to generate the signature on a SAML request to the Identity Provider for a service provider initiated SAML login, when Mattermost is the Service Provider.",
"admin.saml.publicCertificateFileRemoveDesc": "Remove the certificate used to generate the signature on a SAML request to the Identity Provider for a service provider initiated SAML login, when Mattermost is the Service Provider.",
"admin.saml.publicCertificateFileTitle": "Service Provider Public Certificate:",
"admin.saml.remove.idp_certificate": "Remove Identity Provider Certificate",
"admin.saml.remove.privKey": "Remove Service Provider Private Key",
"admin.saml.remove.sp_certificate": "Remove Service Provider Certificate",
"admin.saml.removing.certificate": "Removing Certificate...",
"admin.saml.removing.privKey": "Removing Private Key...",
"admin.saml.uploading.certificate": "Uploading Certificate...",
"admin.saml.uploading.privateKey": "Uploading Private Key...",
"admin.saml.usernameAttrDesc": "The attribute in the SAML Assertion that will be used to populate the username field in Mattermost.",
"admin.saml.usernameAttrEx": "Ex \"Username\"",
"admin.saml.usernameAttrTitle": "Username Attribute:",
"admin.saml.verifyDescription": "When true, Mattermost verifies that the signature sent from the SAML Response matches the Service Provider Login URL",
"admin.saml.verifyTitle": "Verify Signature:",
"admin.save": "Save",
"admin.saving": "Saving Config...",
"admin.security.connection": "Connections",
......@@ -522,6 +578,7 @@
"admin.sidebar.rateLimiting": "Rate Limiting",
"admin.sidebar.reports": "REPORTING",
"admin.sidebar.rmTeamSidebar": "Remove team from sidebar menu",
"admin.sidebar.saml": "SAML",
"admin.sidebar.security": "Security",
"admin.sidebar.sessions": "Sessions",
"admin.sidebar.settings": "SETTINGS",
......@@ -842,7 +899,7 @@
"claim.ldap_to_email.title": "Switch LDAP Account to Email/Password",
"claim.oauth_to_email.confirm": "Confirm Password",
"claim.oauth_to_email.description": "Upon changing your account type, you will only be able to login with your email and password.",
"claim.oauth_to_email.enterNewPwd": "Enter a new password for your {site} account",
"claim.oauth_to_email.enterNewPwd": "Enter a new password for your {site} email account",
"claim.oauth_to_email.enterPwd": "Please enter a password.",
"claim.oauth_to_email.newPwd": "New Password",
"claim.oauth_to_email.pwdNotMatch": "Password do not match.",
......@@ -1454,6 +1511,7 @@
"user.settings.general.emailHelp4": "A verification email was sent to {email}.",
"user.settings.general.emailLdapCantUpdate": "Login occurs through LDAP. Email cannot be updated. Email address used for notifications is {email}.",
"user.settings.general.emailMatch": "The new emails you entered do not match.",
"user.settings.general.emailSamlCantUpdate": "Login occurs through SAML. Email cannot be updated. Email address used for notifications is {email}.",
"user.settings.general.emailUnchanged": "Your new email address is the same as your old email address.",
"user.settings.general.emptyName": "Click 'Edit' to add your full name",