Add SSO support to new user activation (#1504)

Closes #1502. This PR adds support for SSO to the new user creation process. An admin now has the option to select SSO when creating a new user.  When the confirmation form is submitted, the user is automatically authenticated with the IDP, and if successful, is redirected to the Kolide home page. Password authentication, password change and password reset are not allowed for an SSO user.
This commit is contained in:
John Murphy
2017-05-10 11:26:05 -05:00
committed by GitHub
parent 368b9d774c
commit 12d2df1f9a
32 changed files with 711 additions and 75 deletions
+2 -1
View File
@@ -8,7 +8,8 @@ module.exports = {
],
env: {
'node': true,
'mocha': true
'mocha': true,
'browser': true
},
globals: {
'expect': false,
+6 -3
View File
@@ -11,12 +11,15 @@ const userActionOptions = (isCurrentUser, user, invite) => {
if (invite) return inviteActions;
return [
const result = [
userEnableAction,
userPromotionAction,
{ disabled: false, label: 'Require Password Reset', value: 'reset_password' },
{ disabled: false, label: 'Modify Details', value: 'modify_details' },
];
if (!user.sso_enabled) {
result.push({ disabled: false, label: 'Require Password Reset', value: 'reset_password' });
}
result.push({ disabled: false, label: 'Modify Details', value: 'modify_details' });
return result;
};
const userStatusLabel = (user, invite) => {
@@ -0,0 +1,54 @@
import React, { Component, PropTypes } from 'react';
import Form from 'components/forms/Form';
import formFieldInterface from 'interfaces/form_field';
import Button from 'components/buttons/Button';
import InputFieldWithIcon from 'components/forms/fields/InputFieldWithIcon';
import helpers from './helpers';
const formFields = ['name', 'username', 'password', 'password_confirmation'];
const { validate } = helpers;
class ConfirmSSOInviteForm extends Component {
static propTypes = {
baseError: PropTypes.string,
className: PropTypes.string,
fields: PropTypes.shape({
name: formFieldInterface.isRequired,
username: formFieldInterface.isRequired,
password: formFieldInterface.isRequired,
password_confirmation: formFieldInterface.isRequired,
}).isRequired,
handleSubmit: PropTypes.func.isRequired,
};
render () {
const { baseError, className, fields, handleSubmit } = this.props;
return (
<form className={className}>
{baseError && <div className="form__base-error">{baseError}</div>}
<div className="fields">
<InputFieldWithIcon
{...fields.name}
autofocus
placeholder="Full Name"
/>
<InputFieldWithIcon
{...fields.username}
iconName="username"
placeholder="Username"
/>
</div>
<Button onClick={handleSubmit} type="Submit" variant="gradient">
Submit
</Button>
</form>
);
}
}
export default Form(ConfirmSSOInviteForm, {
fields: formFields,
validate,
});
@@ -0,0 +1,122 @@
import React from 'react';
import expect, { createSpy, restoreSpies } from 'expect';
import { mount } from 'enzyme';
import { noop } from 'lodash';
import ConfirmInviteForm from 'components/forms/ConfirmInviteForm';
import { fillInFormInput } from 'test/helpers';
describe('ConfirmInviteForm - component', () => {
afterEach(restoreSpies);
const handleSubmitSpy = createSpy();
const inviteToken = 'abc123';
const formData = { invite_token: inviteToken };
const form = mount(<ConfirmInviteForm formData={formData} handleSubmit={handleSubmitSpy} />);
const nameInput = form.find({ name: 'name' }).find('input');
const passwordConfirmationInput = form.find({ name: 'password_confirmation' }).find('input');
const passwordInput = form.find({ name: 'password' }).find('input');
const submitBtn = form.find('button');
const usernameInput = form.find({ name: 'username' }).find('input');
it('renders', () => {
expect(form.length).toEqual(1);
});
it('renders the base error', () => {
const baseError = 'Unable to authenticate the current user';
const formWithError = mount(<ConfirmInviteForm serverErrors={{ base: baseError }} handleSubmit={noop} />);
const formWithoutError = mount(<ConfirmInviteForm handleSubmit={noop} />);
expect(formWithError.text()).toInclude(baseError);
expect(formWithoutError.text()).toNotInclude(baseError);
});
it('calls the handleSubmit prop with the invite_token when valid', () => {
fillInFormInput(nameInput, 'Gnar Dog');
fillInFormInput(usernameInput, 'gnardog');
fillInFormInput(passwordInput, 'p@ssw0rd');
fillInFormInput(passwordConfirmationInput, 'p@ssw0rd');
submitBtn.simulate('click');
expect(handleSubmitSpy).toHaveBeenCalledWith({
...formData,
name: 'Gnar Dog',
username: 'gnardog',
password: 'p@ssw0rd',
password_confirmation: 'p@ssw0rd',
});
});
describe('name input', () => {
it('changes form state on change', () => {
fillInFormInput(nameInput, 'Gnar Dog');
expect(form.state().formData).toInclude({ name: 'Gnar Dog' });
});
it('validates the field must be present', () => {
fillInFormInput(nameInput, '');
form.find('button').simulate('click');
expect(form.state().errors).toInclude({ name: 'Full name must be present' });
});
});
describe('username input', () => {
it('changes form state on change', () => {
fillInFormInput(usernameInput, 'gnardog');
expect(form.state().formData).toInclude({ username: 'gnardog' });
});
it('validates the field must be present', () => {
fillInFormInput(usernameInput, '');
submitBtn.simulate('click');
expect(form.state().errors).toInclude({ username: 'Username must be present' });
});
});
describe('password input', () => {
it('changes form state on change', () => {
fillInFormInput(passwordInput, 'p@ssw0rd');
expect(form.state().formData).toInclude({ password: 'p@ssw0rd' });
});
it('validates the field must be present', () => {
fillInFormInput(passwordInput, '');
form.find('button').simulate('click');
expect(form.state().errors).toInclude({ password: 'Password must be present' });
});
});
describe('password_confirmation input', () => {
it('changes form state on change', () => {
fillInFormInput(passwordConfirmationInput, 'p@ssw0rd');
expect(form.state().formData).toInclude({ password_confirmation: 'p@ssw0rd' });
});
it('validates the password_confirmation matches the password', () => {
fillInFormInput(passwordInput, 'p@ssw0rd');
fillInFormInput(passwordConfirmationInput, 'another-password');
form.find('button').simulate('click');
expect(form.state().errors).toInclude({
password_confirmation: 'Password confirmation does not match password',
});
});
it('validates the field must be present', () => {
fillInFormInput(passwordConfirmationInput, '');
form.find('button').simulate('click');
expect(form.state().errors).toInclude({ password_confirmation: 'Password confirmation must be present' });
});
});
});
@@ -0,0 +1,23 @@
import { size } from 'lodash';
const validate = (formData) => {
const errors = {};
const {
name,
username,
} = formData;
if (!name) {
errors.name = 'Full name must be present';
}
if (!username) {
errors.username = 'Username must be present';
}
const valid = !size(errors);
return { valid, errors };
};
export default { validate };
@@ -0,0 +1 @@
export default from './ConfirmSSOInviteForm';
@@ -18,6 +18,7 @@ class InviteUserForm extends Component {
invitedBy: userInterface,
onCancel: PropTypes.func,
onSubmit: PropTypes.func,
canUseSSO: PropTypes.bool,
};
constructor (props) {
@@ -28,11 +29,13 @@ class InviteUserForm extends Component {
admin: null,
email: null,
name: null,
sso_enabled: null,
},
formData: {
admin: false,
email: '',
name: '',
sso_enabled: false,
},
};
}
@@ -80,14 +83,14 @@ class InviteUserForm extends Component {
const valid = this.validate();
if (valid) {
const { formData: { admin, email, name } } = this.state;
const { formData: { admin, email, name, sso_enabled } } = this.state;
const { invitedBy, onSubmit } = this.props;
return onSubmit({
admin,
email,
invited_by: invitedBy.id,
name,
sso_enabled,
});
}
@@ -126,7 +129,7 @@ class InviteUserForm extends Component {
}
render () {
const { errors, formData: { admin, email, name } } = this.state;
const { errors, formData: { admin, email, name, ssoEnabled } } = this.state;
const { onCancel, serverErrors } = this.props;
const { onFormSubmit, onInputChange, onCheckboxChange } = this;
const baseError = serverErrors.base;
@@ -162,6 +165,19 @@ class InviteUserForm extends Component {
Enable Admin
</Checkbox>
</div>
<div className={`${baseClass}__radio`}>
<p className={`${baseClass}__role`}>single sign on</p>
<Checkbox
name="sso_enabled"
onChange={onCheckboxChange('sso_enabled')}
value={ssoEnabled}
disabled={!this.props.canUseSSO}
wrapperClassName={`${baseClass}__invite-admin`}
>
Enable Single Sign On
</Checkbox>
</div>
<div className={`${baseClass}__btn-wrap`}>
<Button className={`${baseClass}__btn`} type="submit" variant="brand">
Invite
+1
View File
@@ -37,4 +37,5 @@ export default {
UPDATE_USER_ADMIN: (id) => {
return `/v1/kolide/users/${id}/admin`;
},
SSO: '/v1/kolide/sso',
};
+5
View File
@@ -25,5 +25,10 @@ export default (client) => {
return client.authenticatedPost(endpoint);
},
initializeSSO: (url) => {
const { SSO } = endpoints;
const endpoint = client._endpoint(SSO);
return Base.post(endpoint, JSON.stringify({ relay_url: url }));
},
};
};
@@ -215,6 +215,7 @@ export class UserManagementPage extends Component {
const { currentUser, inviteErrors } = this.props;
const { showInviteUserModal } = this.state;
const { onInviteCancel, onInviteUserSubmit, toggleInviteUserModal } = this;
const ssoEnabledForApp = this.props.config.enable_sso;
if (!showInviteUserModal) {
return false;
@@ -231,6 +232,7 @@ export class UserManagementPage extends Component {
invitedBy={currentUser}
onCancel={onInviteCancel}
onSubmit={onInviteUserSubmit}
canUseSSO={ssoEnabledForApp}
/>
</Modal>
);
@@ -0,0 +1,97 @@
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import AuthenticationFormWrapper from 'components/AuthenticationFormWrapper';
import ConfirmSSOInviteForm from 'components/forms/ConfirmSSOInviteForm';
import EnsureUnauthenticated from 'components/EnsureUnauthenticated';
import userActions from 'redux/nodes/entities/users/actions';
import authActions from 'redux/nodes/auth/actions';
import paths from 'router/paths';
const baseClass = 'confirm-ssoinvite-page';
class ConfirmSSOInvitePage extends Component {
static propTypes = {
dispatch: PropTypes.func,
inviteFormData: PropTypes.shape({
email: PropTypes.string.isRequired,
invite_token: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}).isRequired,
userErrors: PropTypes.shape({
base: PropTypes.string,
}),
};
componentWillUnmount () {
const { dispatch } = this.props;
const { clearErrors } = userActions;
dispatch(clearErrors());
return false;
}
onSubmit = (formData) => {
const { create } = userActions;
const { ssoRedirect } = authActions;
const { dispatch } = this.props;
const { HOME } = paths;
formData.sso_invite = true;
dispatch(create(formData))
.then(() => {
// set redirect so that we will get redirected to home page after
// the user authenticates with the idp
dispatch(ssoRedirect(HOME))
.then((result) => {
window.location.href = result.payload.ssoRedirectURL;
})
.catch(() => false);
})
.catch(() => false);
return false;
}
render () {
const { inviteFormData, userErrors } = this.props;
const { onSubmit } = this;
return (
<AuthenticationFormWrapper>
<div className={`${baseClass}__lead-wrapper`}>
<p className={`${baseClass}__lead-text`}>
Welcome to the party, {inviteFormData.email}!
</p>
<p className={`${baseClass}__sub-lead-text`}>
Please take a moment to fill out the following information before we take you into <b>Kolide</b>
</p>
</div>
<div className={`${baseClass}__form-section-wrapper`}>
<div className={`${baseClass}__form-section-description`}>
<h2>SET USERNAME</h2>
</div>
<ConfirmSSOInviteForm
className={`${baseClass}__form`}
formData={inviteFormData}
handleSubmit={onSubmit}
serverErrors={userErrors}
/>
</div>
</AuthenticationFormWrapper>
);
}
}
const mapStateToProps = (state, { location: urlLocation, params }) => {
const { email, name } = urlLocation.query;
const { invite_token: inviteToken } = params;
const inviteFormData = { email, invite_token: inviteToken, name };
const { errors: userErrors } = state.entities.users;
return { inviteFormData, userErrors };
};
const ConnectedComponent = connect(mapStateToProps)(ConfirmSSOInvitePage);
export default EnsureUnauthenticated(ConnectedComponent);
@@ -0,0 +1,40 @@
import expect from 'expect';
import { mount } from 'enzyme';
import ConfirmInvitePage from 'pages/ConfirmInvitePage';
import { connectedComponent, reduxMockStore } from 'test/helpers';
describe('ConfirmInvitePage - component', () => {
const inviteToken = 'abc123';
const location = { query: { email: 'hi@gnar.dog', name: 'Gnar Dog' } };
const params = { invite_token: inviteToken };
const mockStore = reduxMockStore({ auth: {}, entities: { users: {} } });
const component = connectedComponent(ConfirmInvitePage, {
props: { location, params },
mockStore,
});
const page = mount(component);
it('renders', () => {
expect(page.length).toEqual(1);
expect(
page.find('ConfirmInvitePage').prop('inviteFormData')
).toEqual({
email: 'hi@gnar.dog',
invite_token: inviteToken,
name: 'Gnar Dog',
});
});
it('renders a ConfirmInviteForm', () => {
expect(page.find('ConfirmInviteForm').length).toEqual(1);
});
it('clears errors on unmount', () => {
page.unmount();
expect(mockStore.getActions()).toInclude({
type: 'users_CLEAR_ERRORS',
});
});
});
@@ -0,0 +1,69 @@
.confirm-ssoinvite-page {
&__form-section-description {
h2 {
font-size: 18px;
font-weight: $bold;
line-height: 1.5;
letter-spacing: 0.6px;
color: $text-dark;
margin: 0;
padding: 0;
}
p {
color: $text-dark;
font-size: 14px;
font-weight: $light;
}
}
&__form-section-wrapper {
background-color: $white;
border-radius: 4px;
box-shadow: 0 0 30px 0 rgba(0, 0, 0, 0.3);
box-sizing: border-box;
height: 459px;
padding: 35px;
width: 500px;
margin-bottom: 110px;
}
&__form {
width: 440px;
.fields {
background-color: $white;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
box-shadow: 0 10px 30px 0 rgba(0, 0, 0, 0.3);
box-sizing: border-box;
padding: 30px;
}
}
&__lead-wrapper {
background-color: $white;
border-radius: 4px;
box-shadow: 0 0 30px 0 rgba(0, 0, 0, 0.3);
box-sizing: border-box;
margin-bottom: 30px;
padding: 35px;
width: 500px;
}
&__lead-text {
color: $brand;
font-size: 18px;
font-weight: $light;
margin-top: 0;
text-align: center;
}
&__sub-lead-text {
color: $text-dark;
font-size: 14px;
font-weight: $light;
margin-bottom: 0;
}
}
@@ -0,0 +1 @@
export default from './ConfirmSSOInvitePage';
@@ -196,7 +196,7 @@ export class UserSettingsPage extends Component {
return false;
}
const { admin, updated_at: updatedAt } = user;
const { admin, updated_at: updatedAt, sso_enabled: ssoEnabled } = user;
const roleText = admin ? 'ADMIN' : 'USER';
const lastUpdatedAt = moment(updatedAt).fromNow();
@@ -228,7 +228,7 @@ export class UserSettingsPage extends Component {
<Icon name="lock-big" />
<strong>Password</strong>
</div>
<Button onClick={onShowModal} variant="brand" className={`${baseClass}__button`}>
<Button onClick={onShowModal} variant="brand" disabled={ssoEnabled} className={`${baseClass}__button`}>
CHANGE PASSWORD
</Button>
<p className={`${baseClass}__last-updated`}>Last changed: {lastUpdatedAt}</p>
+3
View File
@@ -3,12 +3,15 @@ export const frontendFormattedConfig = (config) => {
org_info: orgInfo,
server_settings: serverSettings,
smtp_settings: smtpSettings,
sso_settings: ssoSettings,
} = config;
return {
...orgInfo,
...serverSettings,
...smtpSettings,
...ssoSettings,
};
};
+40
View File
@@ -21,6 +21,10 @@ export const PERFORM_REQUIRED_PASSWORD_RESET_REQUEST = 'PERFORM_REQUIRED_PASSWOR
export const PERFORM_REQUIRED_PASSWORD_RESET_SUCCESS = 'PERFORM_REQUIRED_PASSWORD_RESET_SUCCESS';
export const PERFORM_REQUIRED_PASSWORD_RESET_FAILURE = 'PERFORM_REQUIRED_PASSWORD_RESET_FAILURE';
export const SSO_REDIRECT_REQUEST = 'SSO_REDIRECT_REQUEST';
export const SSO_REDIRECT_SUCCESS = 'SSO_REDIRECT_SUCCESS';
export const SSO_REDIRECT_FAILURE = 'SSO_REDIRECT_FAILURE';
export const licenseFailure = (errors) => {
return {
type: LICENSE_FAILURE,
@@ -123,6 +127,38 @@ export const fetchCurrentUser = () => {
};
};
export const ssoRedirectRequest = { type: SSO_REDIRECT_REQUEST };
export const ssoRedirectSuccess = (redirectURL) => {
return {
type: SSO_REDIRECT_SUCCESS,
payload: {
ssoRedirectURL: redirectURL,
},
};
};
export const ssoRedirectFailure = ({ errors }) => {
return {
type: SSO_REDIRECT_FAILURE,
payload: {
errors,
},
};
};
// formData { relay_url: 'some/url'}
export const ssoRedirect = (formData) => {
return (dispatch) => {
dispatch(ssoRedirectRequest);
return Kolide.sessions.initializeSSO(formData)
.then((response) => {
return dispatch(ssoRedirectSuccess(response.url));
}).catch((response) => {
dispatch(ssoRedirectFailure({ base: 'Unable to authenticate the current user' }));
throw response;
});
};
};
// formData should be { username: <string>, password: <string> }
export const loginUser = (formData) => {
return (dispatch) => {
@@ -254,3 +290,7 @@ export const performRequiredPasswordReset = (resetParams) => {
});
};
};
export default {
ssoRedirect,
};
@@ -17,13 +17,69 @@ import {
PERFORM_REQUIRED_PASSWORD_RESET_REQUEST,
PERFORM_REQUIRED_PASSWORD_RESET_FAILURE,
PERFORM_REQUIRED_PASSWORD_RESET_SUCCESS,
SSO_REDIRECT_REQUEST,
SSO_REDIRECT_SUCCESS,
updateUser,
ssoRedirect,
} from './actions';
const store = { entities: { invites: {}, users: {} } };
const user = { ...userStub, id: 1, email: 'zwass@kolide.co', force_password_reset: false };
describe('Auth - actions', () => {
describe('#ssoRedirect', () => {
const ssoURL = 'http://salesforce.idp.com';
const relayURL = '/';
afterEach(restoreSpies);
describe('successful request', () => {
beforeEach(() => {
spyOn(Kolide.sessions, 'initializeSSO').andReturn(Promise.resolve({ url: ssoURL }));
});
it('calls the API', () => {
const mockStore = reduxMockStore(store);
mockStore.dispatch(ssoRedirect(relayURL))
.then(() => {
expect(Kolide.sessions.toHaveBeenCalledWith(relayURL));
})
.catch(() => {
expect(Kolide.sessions.toHaveBeenCalledWith(relayURL));
});
});
it('executes to correct actions', () => {
const mockStore = reduxMockStore(store);
const actions = [
{ type: SSO_REDIRECT_REQUEST },
{ type: SSO_REDIRECT_SUCCESS,
payload: { ssoRedirectURL: ssoURL },
},
];
return mockStore.dispatch(ssoRedirect(relayURL))
.then(() => {
expect(mockStore.getActions()).toEqual(actions);
})
.catch(() => {
expect(mockStore.getActions()).toEqual(actions);
});
});
it('retrieves redirect url', () => {
const mockStore = reduxMockStore(store);
return mockStore.dispatch(ssoRedirect(relayURL))
.then((result) => {
expect(ssoURL).toEqual(result.payload.ssoRedirectURL);
})
.catch((result) => {
expect(ssoURL).toEqual(result);
});
});
});
});
describe('#createLicense', () => {
const license = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ';
afterEach(restoreSpies);
+11
View File
@@ -15,6 +15,9 @@ import {
PERFORM_REQUIRED_PASSWORD_RESET_REQUEST,
PERFORM_REQUIRED_PASSWORD_RESET_SUCCESS,
PERFORM_REQUIRED_PASSWORD_RESET_FAILURE,
SSO_REDIRECT_REQUEST,
SSO_REDIRECT_SUCCESS,
SSO_REDIRECT_FAILURE,
} from './actions';
export const initialState = {
@@ -35,6 +38,7 @@ const reducer = (state = initialState, action) => {
case LOGIN_REQUEST:
case LOGOUT_REQUEST:
case UPDATE_USER_REQUEST:
case SSO_REDIRECT_REQUEST:
return {
...state,
loading: true,
@@ -51,6 +55,13 @@ const reducer = (state = initialState, action) => {
loading: false,
user: action.payload.user,
};
case SSO_REDIRECT_SUCCESS:
return {
...state,
loading: false,
ssoRedirectURL: action.payload.ssoRedirectURL,
};
case SSO_REDIRECT_FAILURE:
case LICENSE_FAILURE:
case LOGIN_FAILURE:
return {
+2
View File
@@ -11,6 +11,7 @@ import AuthenticatedAdminRoutes from 'components/AuthenticatedAdminRoutes';
import AuthenticatedRoutes from 'components/AuthenticatedRoutes';
import ConfigOptionsPage from 'pages/config/ConfigOptionsPage';
import ConfirmInvitePage from 'pages/ConfirmInvitePage';
import ConfirmSSOInvitePage from 'pages/ConfirmSSOInvitePage';
import CoreLayout from 'layouts/CoreLayout';
import EditPackPage from 'pages/packs/EditPackPage';
import EmailTokenRedirect from 'components/EmailTokenRedirect';
@@ -39,6 +40,7 @@ const routes = (
<Route path="license" component={LicensePage} />
<Route path="login" component={LoginRoutes}>
<Route path="invites/:invite_token" component={ConfirmInvitePage} />
<Route path="ssoinvites/:invite_token" component={ConfirmSSOInvitePage} />
<Route path="forgot" />
<Route path="reset" />
</Route>
+7 -5
View File
@@ -10,11 +10,11 @@ import (
func testCreateUser(t *testing.T, ds kolide.Datastore) {
var createTests = []struct {
username, password, email string
isAdmin, passwordReset bool
username, password, email string
isAdmin, passwordReset, sso bool
}{
{"marpaia", "foobar", "mike@kolide.co", true, false},
{"jason", "foobar", "jason@kolide.co", true, false},
{"marpaia", "foobar", "mike@kolide.co", true, false, true},
{"jason", "foobar", "jason@kolide.co", true, false, false},
}
for _, tt := range createTests {
@@ -23,7 +23,8 @@ func testCreateUser(t *testing.T, ds kolide.Datastore) {
Password: []byte(tt.password),
Admin: tt.isAdmin,
AdminForcedPasswordReset: tt.passwordReset,
Email: tt.email,
Email: tt.email,
SSOEnabled: tt.sso,
}
user, err := ds.NewUser(u)
assert.Nil(t, err)
@@ -35,6 +36,7 @@ func testCreateUser(t *testing.T, ds kolide.Datastore) {
assert.Equal(t, tt.username, verify.Username)
assert.Equal(t, tt.email, verify.Email)
assert.Equal(t, tt.email, verify.Email)
assert.Equal(t, tt.sso, verify.SSOEnabled)
}
}
+7 -7
View File
@@ -18,13 +18,13 @@ func (d *Datastore) NewInvite(i *kolide.Invite) (*kolide.Invite, error) {
switch err {
case nil:
sqlStmt = `
REPLACE INTO invites ( invited_by, email, admin, name, position, token, deleted)
VALUES ( ?, ?, ?, ?, ?, ?, ?)
REPLACE INTO invites ( invited_by, email, admin, name, position, token, deleted, sso_enabled)
VALUES ( ?, ?, ?, ?, ?, ?, ?, ?)
`
case sql.ErrNoRows:
sqlStmt = `
INSERT INTO invites ( invited_by, email, admin, name, position, token, deleted)
VALUES ( ?, ?, ?, ?, ?, ?, ?)
INSERT INTO invites ( invited_by, email, admin, name, position, token, deleted, sso_enabled)
VALUES ( ?, ?, ?, ?, ?, ?, ?, ?)
`
default:
return nil, errors.Wrap(err, "check for existing invite")
@@ -32,7 +32,7 @@ func (d *Datastore) NewInvite(i *kolide.Invite) (*kolide.Invite, error) {
deleted := false
result, err := d.db.Exec(sqlStmt, i.InvitedBy, i.Email, i.Admin,
i.Name, i.Position, i.Token, deleted)
i.Name, i.Position, i.Token, deleted, i.SSOEnabled)
if err != nil && isDuplicate(err) {
return nil, alreadyExists("Invite", 0)
} else if err != nil {
@@ -104,11 +104,11 @@ func (d *Datastore) InviteByToken(token string) (*kolide.Invite, error) {
func (d *Datastore) SaveInvite(i *kolide.Invite) error {
sql := `
UPDATE invites SET invited_by = ?, email = ?, admin = ?,
name = ?, position = ?, token = ?
name = ?, position = ?, token = ?, sso_enabled = ?
WHERE id = ? AND NOT deleted
`
results, err := d.db.Exec(sql, i.InvitedBy, i.Email,
i.Admin, i.Name, i.Position, i.Token, i.ID,
i.Admin, i.Name, i.Position, i.Token, i.SSOEnabled, i.ID,
)
if err != nil {
return errors.Wrap(err, "save invite")
@@ -0,0 +1,19 @@
package tables
import (
"database/sql"
)
func init() {
MigrationClient.AddMigration(Up_20170504130602, Down_20170504130602)
}
func Up_20170504130602(tx *sql.Tx) error {
_, err := tx.Exec("ALTER TABLE `kolide`.`invites` ADD COLUMN `sso_enabled` TINYINT(1) NOT NULL DEFAULT FALSE AFTER `token`;")
return err
}
func Down_20170504130602(tx *sql.Tx) error {
_, err := tx.Exec("ALTER TABLE `kolide`.`invites` DROP COLUMN `sso_enabled`;")
return err
}
@@ -0,0 +1,19 @@
package tables
import (
"database/sql"
)
func init() {
MigrationClient.AddMigration(Up_20170509132100, Down_20170509132100)
}
func Up_20170509132100(tx *sql.Tx) error {
_, err := tx.Exec("ALTER TABLE `kolide`.`users` ADD COLUMN `sso_enabled` TINYINT NOT NULL DEFAULT FALSE AFTER `position`;")
return err
}
func Down_20170509132100(tx *sql.Tx) error {
_, err := tx.Exec("ALTER TABLE `kolide`.`users` DROP COLUMN `sso_enabled` ;")
return err
}
+30 -28
View File
@@ -11,22 +11,23 @@ import (
// NewUser creates a new user
func (d *Datastore) NewUser(user *kolide.User) (*kolide.User, error) {
sqlStatement := `
INSERT INTO users (
password,
salt,
name,
username,
email,
admin,
enabled,
admin_forced_password_reset,
gravatar_url,
position
) VALUES (?,?,?,?,?,?,?,?,?,?)
`
INSERT INTO users (
password,
salt,
name,
username,
email,
admin,
enabled,
admin_forced_password_reset,
gravatar_url,
position,
sso_enabled
) VALUES (?,?,?,?,?,?,?,?,?,?,?)
`
result, err := d.db.Exec(sqlStatement, user.Password, user.Salt, user.Name,
user.Username, user.Email, user.Admin, user.Enabled,
user.AdminForcedPasswordReset, user.GravatarURL, user.Position)
user.AdminForcedPasswordReset, user.GravatarURL, user.Position, user.SSOEnabled)
if err != nil {
return nil, errors.Wrap(err, "create new user")
}
@@ -88,22 +89,23 @@ func (d *Datastore) UserByID(id uint) (*kolide.User, error) {
func (d *Datastore) SaveUser(user *kolide.User) error {
sqlStatement := `
UPDATE users SET
username = ?,
password = ?,
salt = ?,
name = ?,
email = ?,
admin = ?,
enabled = ?,
admin_forced_password_reset = ?,
gravatar_url = ?,
position = ?
WHERE id = ?
`
UPDATE users SET
username = ?,
password = ?,
salt = ?,
name = ?,
email = ?,
admin = ?,
enabled = ?,
admin_forced_password_reset = ?,
gravatar_url = ?,
position = ?,
sso_enabled = ?
WHERE id = ?
`
result, err := d.db.Exec(sqlStatement, user.Username, user.Password,
user.Salt, user.Name, user.Email, user.Admin, user.Enabled,
user.AdminForcedPasswordReset, user.GravatarURL, user.Position, user.ID)
user.AdminForcedPasswordReset, user.GravatarURL, user.Position, user.SSOEnabled, user.ID)
if err != nil {
return errors.Wrap(err, "save user")
}
+14 -12
View File
@@ -50,24 +50,26 @@ type InviteService interface {
// InvitePayload contains fields required to create a new user invite.
type InvitePayload struct {
InvitedBy *uint `json:"invited_by"`
Email *string
Admin *bool
Name *string
Position *string
InvitedBy *uint `json:"invited_by"`
Email *string
Admin *bool
Name *string
Position *string
SSOEnabled *bool `json:"sso_enabled"`
}
// Invite represents an invitation for a user to join Kolide.
type Invite struct {
UpdateCreateTimestamps
DeleteFields
ID uint `json:"id"`
InvitedBy uint `json:"invited_by" db:"invited_by"`
Email string `json:"email"`
Admin bool `json:"admin"`
Name string `json:"name"`
Position string `json:"position,omitempty"`
Token string `json:"-"`
ID uint `json:"id"`
InvitedBy uint `json:"invited_by" db:"invited_by"`
Email string `json:"email"`
Admin bool `json:"admin"`
Name string `json:"name"`
Position string `json:"position,omitempty"`
Token string `json:"-"`
SSOEnabled bool `json:"sso_enabled" db:"sso_enabled"`
}
// InviteMailer is used to build an email template for the invite email.
+3 -1
View File
@@ -101,6 +101,8 @@ type User struct {
AdminForcedPasswordReset bool `json:"force_password_reset" db:"admin_forced_password_reset"`
GravatarURL string `json:"gravatar_url" db:"gravatar_url"`
Position string `json:"position,omitempty"` // job role
// SSOEnabled if true, the single siqn on is used to log in
SSOEnabled bool `json:"sso_enabled" db:"sso_enabled"`
}
// UserPayload is used to modify an existing user
@@ -114,11 +116,11 @@ type UserPayload struct {
GravatarURL *string `json:"gravatar_url"`
Position *string `json:"position"`
InviteToken *string `json:"invite_token"`
SSOInvite *bool `json:"sso_invite"`
}
// User creates a user from payload.
func (p UserPayload) User(keySize, cost int) (*User, error) {
user := &User{
Username: *p.Username,
Email: *p.Email,
+5 -1
View File
@@ -58,7 +58,11 @@
<table bgcolor="#f4f6fb" height="100px" cellpadding="20px">
<tr>
<td style="font-family: 'Oxygen', Arial, sans-serif;">
<a href="{{.KolideServerURL}}/login/invites/{{.Token}}?name={{.Name}}&email={{.Email}}">{{.KolideServerURL}}/login/invites/{{.Token}}?name={{.Name}}&email={{.Email}}</a>
{{if .SSOEnabled}}
<a href="{{.KolideServerURL}}/login/ssoinvites/{{.Token}}?name={{.Name}}&email={{.Email}}">{{.KolideServerURL}}/login/ssoinvites/{{.Token}}?name={{.Name}}&email={{.Email}}</a>
{{else}}
<a href="{{.KolideServerURL}}/login/invites/{{.Token}}?name={{.Name}}&email={{.Email}}">{{.KolideServerURL}}/login/invites/{{.Token}}?name={{.Name}}&email={{.Email}}</a>
{{end}}
</td>
</tr>
</table>
+3
View File
@@ -43,6 +43,9 @@ func (svc service) InviteNewUser(ctx context.Context, payload kolide.InvitePaylo
if payload.Name != nil {
invite.Name = *payload.Name
}
if payload.SSOEnabled != nil {
invite.SSOEnabled = *payload.SSOEnabled
}
invite, err = svc.ds.NewInvite(invite)
if err != nil {
+8
View File
@@ -87,6 +87,10 @@ func (svc service) CallbackSSO(ctx context.Context, auth kolide.Auth) (*kolide.S
if err != nil {
return nil, errors.Wrap(err, "finding user in sso callback")
}
// if user is not active they are not authorized to use the application
if !user.Enabled || user.Deleted {
return nil, errors.New("user authorization failed")
}
token, err := svc.makeSession(user.ID)
if err != nil {
return nil, errors.Wrap(err, "making user session in sso callback")
@@ -112,6 +116,10 @@ func (svc service) Login(ctx context.Context, username, password string) (*kolid
if !user.Enabled {
return nil, "", authError{reason: "account disabled", clientReason: "account disabled"}
}
if user.SSOEnabled {
const errMessage = "password login not allowed for single sign on users"
return nil, "", authError{reason: errMessage, clientReason: errMessage}
}
if err = user.ValidatePassword(password); err != nil {
return nil, "", authError{reason: "bad password"}
}
+29 -4
View File
@@ -38,10 +38,21 @@ func (svc service) NewAdminCreatedUser(ctx context.Context, p kolide.UserPayload
}
func (svc service) newUser(p kolide.UserPayload) (*kolide.User, error) {
var ssoEnabled bool
// if user is SSO generate a fake password
if p.SSOInvite != nil && *p.SSOInvite == true {
fakePassword, err := generateRandomText(14)
if err != nil {
return nil, err
}
p.Password = &fakePassword
ssoEnabled = true
}
user, err := p.User(svc.config.Auth.SaltKeySize, svc.config.Auth.BcryptCost)
if err != nil {
return nil, err
}
user.SSOEnabled = ssoEnabled
user, err = svc.ds.NewUser(user)
if err != nil {
return nil, err
@@ -192,7 +203,9 @@ func (svc service) setNewPassword(ctx context.Context, user *kolide.User, passwo
if err != nil {
return errors.Wrap(err, "setting new password")
}
if user.SSOEnabled {
return errors.New("set password for single sign on user not allowed")
}
err = svc.saveUser(user)
if err != nil {
return errors.Wrap(err, "saving changed password")
@@ -206,7 +219,9 @@ func (svc service) ChangePassword(ctx context.Context, oldPass, newPass string)
if !ok {
return errNoContext
}
if vc.User.SSOEnabled {
return errors.New("change password for single sign on user not allowed")
}
if err := vc.User.ValidatePassword(newPass); err == nil {
return newInvalidArgumentError("new_password", "cannot reuse old password")
}
@@ -230,6 +245,9 @@ func (svc service) ResetPassword(ctx context.Context, token, password string) er
if err != nil {
return errors.Wrap(err, "retrieving user")
}
if user.SSOEnabled {
return errors.New("password reset for single sign on user not allowed")
}
// prevent setting the same password
if err := user.ValidatePassword(password); err == nil {
@@ -261,7 +279,9 @@ func (svc service) PerformRequiredPasswordReset(ctx context.Context, password st
return nil, errNoContext
}
user := vc.User
if user.SSOEnabled {
return nil, errors.New("password reset for single sign on user not allowed")
}
if !user.AdminForcedPasswordReset {
return nil, errors.New("user does not require password reset")
}
@@ -288,7 +308,9 @@ func (svc service) RequirePasswordReset(ctx context.Context, uid uint, require b
if err != nil {
return nil, errors.Wrap(err, "loading user by ID")
}
if user.SSOEnabled {
return nil, errors.New("password reset for single sign on user not allowed")
}
// Require reset on next login
user.AdminForcedPasswordReset = require
if err := svc.saveUser(user); err != nil {
@@ -310,6 +332,9 @@ func (svc service) RequestPasswordReset(ctx context.Context, email string) error
if err != nil {
return err
}
if user.SSOEnabled {
return errors.New("password reset for single sign on user not allowed")
}
random, err := kolide.RandomText(svc.config.App.TokenKeySize)
if err != nil {
+11 -8
View File
@@ -24,14 +24,17 @@ func (mw validationMiddleware) NewUser(ctx context.Context, p kolide.UserPayload
}
}
if p.Password == nil {
invalid.Append("password", "missing required argument")
} else {
if *p.Password == "" {
invalid.Append("password", "cannot be empty")
}
if err := validatePasswordRequirements(*p.Password); err != nil {
invalid.Append("password", err.Error())
// we don't need a password for single sign on
if p.SSOInvite == nil || *p.SSOInvite == false {
if p.Password == nil {
invalid.Append("password", "missing required argument")
} else {
if *p.Password == "" {
invalid.Append("password", "cannot be empty")
}
if err := validatePasswordRequirements(*p.Password); err != nil {
invalid.Append("password", err.Error())
}
}
}