diff --git a/.eslintrc.js b/.eslintrc.js index 6203ad10c9..93c132eb86 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -8,7 +8,8 @@ module.exports = { ], env: { 'node': true, - 'mocha': true + 'mocha': true, + 'browser': true }, globals: { 'expect': false, diff --git a/frontend/components/UserBlock/helpers.js b/frontend/components/UserBlock/helpers.js index 884eb83ee1..cf5a6ca42c 100644 --- a/frontend/components/UserBlock/helpers.js +++ b/frontend/components/UserBlock/helpers.js @@ -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) => { diff --git a/frontend/components/forms/ConfirmSSOInviteForm/ConfirmSSOInviteForm.jsx b/frontend/components/forms/ConfirmSSOInviteForm/ConfirmSSOInviteForm.jsx new file mode 100644 index 0000000000..b103f7d628 --- /dev/null +++ b/frontend/components/forms/ConfirmSSOInviteForm/ConfirmSSOInviteForm.jsx @@ -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 ( +
+ {baseError &&
{baseError}
} +
+ + +
+ +
+ ); + } +} + +export default Form(ConfirmSSOInviteForm, { + fields: formFields, + validate, +}); diff --git a/frontend/components/forms/ConfirmSSOInviteForm/ConfirmSSOInviteForm.tests.jsx b/frontend/components/forms/ConfirmSSOInviteForm/ConfirmSSOInviteForm.tests.jsx new file mode 100644 index 0000000000..2b456d109a --- /dev/null +++ b/frontend/components/forms/ConfirmSSOInviteForm/ConfirmSSOInviteForm.tests.jsx @@ -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(); + + 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(); + const formWithoutError = mount(); + + 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' }); + }); + }); +}); + diff --git a/frontend/components/forms/ConfirmSSOInviteForm/helpers.js b/frontend/components/forms/ConfirmSSOInviteForm/helpers.js new file mode 100644 index 0000000000..62d7193efc --- /dev/null +++ b/frontend/components/forms/ConfirmSSOInviteForm/helpers.js @@ -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 }; diff --git a/frontend/components/forms/ConfirmSSOInviteForm/index.js b/frontend/components/forms/ConfirmSSOInviteForm/index.js new file mode 100644 index 0000000000..4a9113f3cf --- /dev/null +++ b/frontend/components/forms/ConfirmSSOInviteForm/index.js @@ -0,0 +1 @@ +export default from './ConfirmSSOInviteForm'; diff --git a/frontend/components/forms/InviteUserForm/InviteUserForm.jsx b/frontend/components/forms/InviteUserForm/InviteUserForm.jsx index ad3ded2b49..d6c7733769 100644 --- a/frontend/components/forms/InviteUserForm/InviteUserForm.jsx +++ b/frontend/components/forms/InviteUserForm/InviteUserForm.jsx @@ -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 +
+

single sign on

+ + Enable Single Sign On + +
+
-

Last changed: {lastUpdatedAt}

diff --git a/frontend/redux/nodes/app/helpers.js b/frontend/redux/nodes/app/helpers.js index ecf4356c1b..44ce7a2fa2 100644 --- a/frontend/redux/nodes/app/helpers.js +++ b/frontend/redux/nodes/app/helpers.js @@ -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, }; }; diff --git a/frontend/redux/nodes/auth/actions.js b/frontend/redux/nodes/auth/actions.js index 3995c99000..f41c76574d 100644 --- a/frontend/redux/nodes/auth/actions.js +++ b/frontend/redux/nodes/auth/actions.js @@ -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: , password: } export const loginUser = (formData) => { return (dispatch) => { @@ -254,3 +290,7 @@ export const performRequiredPasswordReset = (resetParams) => { }); }; }; + +export default { + ssoRedirect, +}; diff --git a/frontend/redux/nodes/auth/actions.tests.js b/frontend/redux/nodes/auth/actions.tests.js index 4255e79159..0cf9925e2e 100644 --- a/frontend/redux/nodes/auth/actions.tests.js +++ b/frontend/redux/nodes/auth/actions.tests.js @@ -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); diff --git a/frontend/redux/nodes/auth/reducer.js b/frontend/redux/nodes/auth/reducer.js index c40c571cf1..eb702d6ae5 100644 --- a/frontend/redux/nodes/auth/reducer.js +++ b/frontend/redux/nodes/auth/reducer.js @@ -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 { diff --git a/frontend/router/index.jsx b/frontend/router/index.jsx index 602575fffd..05f3bc86bc 100644 --- a/frontend/router/index.jsx +++ b/frontend/router/index.jsx @@ -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 = ( + diff --git a/server/datastore/datastore_users_test.go b/server/datastore/datastore_users_test.go index 6aed59e4ff..ab4bef9ba5 100644 --- a/server/datastore/datastore_users_test.go +++ b/server/datastore/datastore_users_test.go @@ -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) } } diff --git a/server/datastore/mysql/invites.go b/server/datastore/mysql/invites.go index 40001d7682..1e30230882 100644 --- a/server/datastore/mysql/invites.go +++ b/server/datastore/mysql/invites.go @@ -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") diff --git a/server/datastore/mysql/migrations/tables/20170504130602_AddSSOColToInvites.go b/server/datastore/mysql/migrations/tables/20170504130602_AddSSOColToInvites.go new file mode 100644 index 0000000000..c20c3f23be --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20170504130602_AddSSOColToInvites.go @@ -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 +} diff --git a/server/datastore/mysql/migrations/tables/20170509132100_AddSSOFlagToUser.go b/server/datastore/mysql/migrations/tables/20170509132100_AddSSOFlagToUser.go new file mode 100644 index 0000000000..6231cffd8f --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20170509132100_AddSSOFlagToUser.go @@ -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 +} diff --git a/server/datastore/mysql/users.go b/server/datastore/mysql/users.go index addceaf47d..a7ff8dca61 100644 --- a/server/datastore/mysql/users.go +++ b/server/datastore/mysql/users.go @@ -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") } diff --git a/server/kolide/invites.go b/server/kolide/invites.go index eada1f1d9e..9afc63e4ff 100644 --- a/server/kolide/invites.go +++ b/server/kolide/invites.go @@ -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. diff --git a/server/kolide/users.go b/server/kolide/users.go index ae6572fc73..996f668e8e 100644 --- a/server/kolide/users.go +++ b/server/kolide/users.go @@ -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, diff --git a/server/mail/templates/invite_token.html b/server/mail/templates/invite_token.html index 3eaaa1bafa..6ea02ea52a 100644 --- a/server/mail/templates/invite_token.html +++ b/server/mail/templates/invite_token.html @@ -58,7 +58,11 @@
- {{.KolideServerURL}}/login/invites/{{.Token}}?name={{.Name}}&email={{.Email}} + {{if .SSOEnabled}} + {{.KolideServerURL}}/login/ssoinvites/{{.Token}}?name={{.Name}}&email={{.Email}} + {{else}} + {{.KolideServerURL}}/login/invites/{{.Token}}?name={{.Name}}&email={{.Email}} + {{end}}
diff --git a/server/service/service_invites.go b/server/service/service_invites.go index ce635fc6aa..f9f191ee1d 100644 --- a/server/service/service_invites.go +++ b/server/service/service_invites.go @@ -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 { diff --git a/server/service/service_sessions.go b/server/service/service_sessions.go index 0c76de6c0b..b47540ed53 100644 --- a/server/service/service_sessions.go +++ b/server/service/service_sessions.go @@ -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"} } diff --git a/server/service/service_users.go b/server/service/service_users.go index d899cd5e0e..7a3cf239a4 100644 --- a/server/service/service_users.go +++ b/server/service/service_users.go @@ -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 { diff --git a/server/service/validation_users.go b/server/service/validation_users.go index ca2288aaad..1f8bdb9fe8 100644 --- a/server/service/validation_users.go +++ b/server/service/validation_users.go @@ -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()) + } } }