Skip to content

Commit

Permalink
Implement AnonymousAuthenticationProvider.
Browse files Browse the repository at this point in the history
  • Loading branch information
azasypkin committed Nov 4, 2020
1 parent 5fcd124 commit 02d2c17
Show file tree
Hide file tree
Showing 16 changed files with 1,003 additions and 93 deletions.
2 changes: 2 additions & 0 deletions x-pack/plugins/security/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ export const UNKNOWN_SPACE = '?';
export const GLOBAL_RESOURCE = '*';
export const APPLICATION_PREFIX = 'kibana-';
export const RESERVED_PRIVILEGES_APPLICATION_WILDCARD = 'kibana-*';

export const AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER = 'auth_provider_hint';
43 changes: 42 additions & 1 deletion x-pack/plugins/security/common/model/authenticated_user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { AuthenticatedUser, canUserChangePassword } from './authenticated_user';
import { AuthenticatedUser, canEditProfile, canUserChangePassword } from './authenticated_user';

describe('#canUserChangePassword', () => {
['reserved', 'native'].forEach((realm) => {
Expand All @@ -20,6 +20,19 @@ describe('#canUserChangePassword', () => {
} as AuthenticatedUser)
).toEqual(true);
});

it(`returns false for users in the ${realm} realm if used for anonymous access`, () => {
expect(
canUserChangePassword({
username: 'foo',
authentication_provider: { type: 'anonymous', name: 'does not matter' },
authentication_realm: {
name: 'the realm name',
type: realm,
},
} as AuthenticatedUser)
).toEqual(false);
});
});

it(`returns false for all other realms`, () => {
Expand All @@ -35,3 +48,31 @@ describe('#canUserChangePassword', () => {
).toEqual(false);
});
});

describe('#canEditProfile', () => {
it('returns false for users using anonymous access', () => {
expect(
canEditProfile({
username: 'foo',
authentication_provider: { type: 'anonymous', name: 'does not matter' },
authentication_realm: {
name: 'the realm name',
type: 'does not matter',
},
} as AuthenticatedUser)
).toEqual(false);
});

it('returns true for all other providers', () => {
expect(
canEditProfile({
username: 'foo',
authentication_provider: { type: 'the provider type', name: 'does not matter' },
authentication_realm: {
name: 'the realm name',
type: 'does not matter',
},
} as AuthenticatedUser)
).toEqual(true);
});
});
9 changes: 8 additions & 1 deletion x-pack/plugins/security/common/model/authenticated_user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,12 @@ export interface AuthenticatedUser extends User {
}

export function canUserChangePassword(user: AuthenticatedUser) {
return REALMS_ELIGIBLE_FOR_PASSWORD_CHANGE.includes(user.authentication_realm.type);
return (
REALMS_ELIGIBLE_FOR_PASSWORD_CHANGE.includes(user.authentication_realm.type) &&
user.authentication_provider.type !== 'anonymous'
);
}

export function canEditProfile(user: AuthenticatedUser) {
return user.authentication_provider.type !== 'anonymous';
}
2 changes: 1 addition & 1 deletion x-pack/plugins/security/common/model/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

export { ApiKey, ApiKeyToInvalidate } from './api_key';
export { User, EditUser, getUserDisplayName } from './user';
export { AuthenticatedUser, canUserChangePassword } from './authenticated_user';
export { AuthenticatedUser, canUserChangePassword, canEditProfile } from './authenticated_user';
export { BuiltinESPrivileges } from './builtin_es_privileges';
export { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges';
export { FeaturesPrivileges } from './features_privileges';
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ import {
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { HttpStart, IHttpFetchError, NotificationsStart } from 'src/core/public';
import { LoginSelector } from '../../../../../common/login_state';
import { SectionLoading } from '../../../../../../../../src/plugins/es_ui_shared/public';
import type { LoginSelector, LoginSelectorProvider } from '../../../../../common/login_state';
import { LoginValidator } from './validate_login';

interface Props {
Expand All @@ -39,12 +40,12 @@ interface Props {
infoMessage?: string;
loginAssistanceMessage: string;
loginHelp?: string;
authProviderHint?: string;
}

interface State {
loadingState:
| { type: LoadingStateType.None }
| { type: LoadingStateType.Form }
| { type: LoadingStateType.None | LoadingStateType.Form | LoadingStateType.AutoLogin }
| { type: LoadingStateType.Selector; providerName: string };
username: string;
password: string;
Expand All @@ -59,6 +60,7 @@ enum LoadingStateType {
None,
Form,
Selector,
AutoLogin,
}

enum MessageType {
Expand All @@ -76,11 +78,26 @@ export enum PageMode {
export class LoginForm extends Component<Props, State> {
private readonly validator: LoginValidator;

/**
* Optional provider that was suggested by the `authProviderHint={providerName}` query string parameter. If provider
* doesn't require Kibana native login form then login process is triggered automatically, otherwise Login Selector
* just switches to the Login Form mode.
*/
private readonly suggestedProvider?: LoginSelectorProvider;

constructor(props: Props) {
super(props);
this.validator = new LoginValidator({ shouldValidate: false });

const mode = this.showLoginSelector() ? PageMode.Selector : PageMode.Form;
this.suggestedProvider = this.props.authProviderHint
? this.props.selector.providers.find(({ name }) => name === this.props.authProviderHint)
: undefined;

// Switch to the Form mode right away if provider from the hint requires it.
const mode =
this.showLoginSelector() && !this.suggestedProvider?.usesLoginForm
? PageMode.Selector
: PageMode.Form;

this.state = {
loadingState: { type: LoadingStateType.None },
Expand All @@ -94,6 +111,12 @@ export class LoginForm extends Component<Props, State> {
};
}

async componentDidMount() {
if (this.suggestedProvider?.usesLoginForm === false) {
await this.loginWithSelector({ provider: this.suggestedProvider, autoLogin: true });
}
}

public render() {
return (
<Fragment>
Expand Down Expand Up @@ -160,7 +183,9 @@ export class LoginForm extends Component<Props, State> {
case PageMode.Form:
return this.renderLoginForm();
case PageMode.Selector:
return this.renderSelector();
return this.isLoadingState(LoadingStateType.AutoLogin)
? this.renderAutoLoginOverlay()
: this.renderSelector();
case PageMode.LoginHelp:
return this.renderLoginHelp();
}
Expand Down Expand Up @@ -267,7 +292,7 @@ export class LoginForm extends Component<Props, State> {
onClick={() =>
provider.usesLoginForm
? this.onPageModeChange(PageMode.Form)
: this.loginWithSelector(provider.type, provider.name)
: this.loginWithSelector({ provider })
}
className={`secLoginCard ${
this.isLoadingState(LoadingStateType.Selector, provider.name)
Expand Down Expand Up @@ -360,6 +385,19 @@ export class LoginForm extends Component<Props, State> {
return null;
};

private renderAutoLoginOverlay = () => {
return (
<EuiPanel data-test-subj="loginSelector" paddingSize="none">
<SectionLoading>
<FormattedMessage
id="xpack.security.loginPage.autoLoginLabel"
defaultMessage="Logging in…"
/>
</SectionLoading>
</EuiPanel>
);
};

private setUsernameInputRef(ref: HTMLInputElement) {
if (ref) {
ref.focus();
Expand Down Expand Up @@ -438,9 +476,17 @@ export class LoginForm extends Component<Props, State> {
}
};

private loginWithSelector = async (providerType: string, providerName: string) => {
private loginWithSelector = async ({
provider: { type: providerType, name: providerName },
autoLogin,
}: {
provider: LoginSelectorProvider;
autoLogin?: boolean;
}) => {
this.setState({
loadingState: { type: LoadingStateType.Selector, providerName },
loadingState: autoLogin
? { type: LoadingStateType.AutoLogin }
: { type: LoadingStateType.Selector, providerName },
message: { type: MessageType.None },
});

Expand All @@ -466,7 +512,9 @@ export class LoginForm extends Component<Props, State> {
}
};

private isLoadingState(type: LoadingStateType.None | LoadingStateType.Form): boolean;
private isLoadingState(
type: LoadingStateType.None | LoadingStateType.Form | LoadingStateType.AutoLogin
): boolean;
private isLoadingState(type: LoadingStateType.Selector, providerName: string): boolean;
private isLoadingState(type: LoadingStateType, providerName?: string) {
const { loadingState } = this.state;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import React from 'react';
import { shallow } from 'enzyme';
import { act } from '@testing-library/react';
import { nextTick } from 'test_utils/enzyme_helpers';
import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER } from '../../../common/constants';
import { LoginState } from '../../../common/login_state';
import { LoginPage } from './login_page';
import { coreMock } from '../../../../../../src/core/public/mocks';
Expand Down Expand Up @@ -37,14 +38,12 @@ describe('LoginPage', () => {
httpMock.addLoadingCountSource.mockReset();
};

beforeAll(() => {
beforeEach(() => {
Object.defineProperty(window, 'location', {
value: { href: 'http://some-host/bar', protocol: 'http' },
writable: true,
});
});

beforeEach(() => {
resetHttpMock();
});

Expand Down Expand Up @@ -206,10 +205,10 @@ describe('LoginPage', () => {
expect(wrapper.find(LoginForm)).toMatchSnapshot();
});

it('renders as expected when info message is set', async () => {
it('properly passes query string parameters to the form', async () => {
const coreStartMock = coreMock.createStart();
httpMock.get.mockResolvedValue(createLoginState());
window.location.href = 'http://some-host/bar?msg=SESSION_EXPIRED';
window.location.href = `http://some-host/bar?msg=SESSION_EXPIRED&${AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER}=basic1`;

const wrapper = shallow(
<LoginPage
Expand All @@ -226,7 +225,9 @@ describe('LoginPage', () => {
resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot
});

expect(wrapper.find(LoginForm)).toMatchSnapshot();
const { authProviderHint, infoMessage } = wrapper.find(LoginForm).props();
expect(authProviderHint).toBe('basic1');
expect(infoMessage).toBe('Your session has timed out. Please log in again.');
});

it('renders as expected when loginAssistanceMessage is set', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer, EuiTitle } from '@elasti
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { CoreStart, FatalErrorsStart, HttpStart, NotificationsStart } from 'src/core/public';
import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER } from '../../../common/constants';
import { LoginState } from '../../../common/login_state';
import { LoginForm, DisabledLoginForm } from './components';

Expand Down Expand Up @@ -212,14 +213,16 @@ export class LoginPage extends Component<Props, State> {
);
}

const query = parse(window.location.href, true).query;
return (
<LoginForm
http={this.props.http}
notifications={this.props.notifications}
selector={selector}
infoMessage={infoMessageMap.get(parse(window.location.href, true).query.msg?.toString())}
infoMessage={infoMessageMap.get(query.msg?.toString())}
loginAssistanceMessage={this.props.loginAssistanceMessage}
loginHelp={loginHelp}
authProviderHint={query[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]?.toString()}
/>
);
};
Expand Down
Loading

0 comments on commit 02d2c17

Please sign in to comment.