Migrate the rest of the API endpoints to the New Platform plugin (#50695)

This commit is contained in:
Aleh Zasypkin 2019-12-11 18:35:49 +01:00 committed by GitHub
parent a91e53f18f
commit 2ec82d3dd9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
92 changed files with 2885 additions and 2713 deletions

View file

@ -163,7 +163,7 @@ required by {kib}. If you want to use Third Party initiated SSO , then you must
+
[source,yaml]
--------------------------------------------------------------------------------
server.xsrf.whitelist: [/api/security/v1/oidc]
server.xsrf.whitelist: [/api/security/oidc/initiate_login]
--------------------------------------------------------------------------------
[float]

View file

@ -38,7 +38,7 @@ export class User {
public async create(username: string, user: any) {
this.log.debug(`creating user ${username}`);
const { data, status, statusText } = await this.axios.post(
`/api/security/v1/users/${username}`,
`/internal/security/users/${username}`,
{
username,
...user,
@ -55,7 +55,7 @@ export class User {
public async delete(username: string) {
this.log.debug(`deleting user ${username}`);
const { data, status, statusText } = await this.axios.delete(
`/api/security/v1/users/${username}`
`/internal/security/users/${username}`
);
if (status !== 204) {
throw new Error(

View file

@ -183,7 +183,7 @@ async function createOrUpdateUser(newUser: User) {
async function createUser(newUser: User) {
const user = await callKibana<User>({
method: 'POST',
url: `/api/security/v1/users/${newUser.username}`,
url: `/internal/security/users/${newUser.username}`,
data: {
...newUser,
enabled: true,
@ -209,7 +209,7 @@ async function updateUser(existingUser: User, newUser: User) {
// assign role to user
await callKibana({
method: 'POST',
url: `/api/security/v1/users/${username}`,
url: `/internal/security/users/${username}`,
data: { ...existingUser, roles: allRoles }
});
@ -219,7 +219,7 @@ async function updateUser(existingUser: User, newUser: User) {
async function getUser(username: string) {
try {
return await callKibana<User>({
url: `/api/security/v1/users/${username}`
url: `/internal/security/users/${username}`
});
} catch (e) {
const err = e as AxiosError;

View file

@ -4,8 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { ApiKey } from './api_key';
export {
ApiKey,
ApiKeyToInvalidate,
AuthenticatedUser,
BuiltinESPrivileges,
EditUser,
@ -19,4 +20,4 @@ export {
User,
canUserChangePassword,
getUserDisplayName,
} from '../../../../../plugins/security/common/model';
} from '../../../../plugins/security/common/model';

View file

@ -5,10 +5,6 @@
*/
import { resolve } from 'path';
import { initAuthenticateApi } from './server/routes/api/v1/authenticate';
import { initUsersApi } from './server/routes/api/v1/users';
import { initApiKeysApi } from './server/routes/api/v1/api_keys';
import { initIndicesApi } from './server/routes/api/v1/indices';
import { initOverwrittenSessionView } from './server/routes/views/overwritten_session';
import { initLoginView } from './server/routes/views/login';
import { initLogoutView } from './server/routes/views/logout';
@ -34,7 +30,7 @@ export const security = (kibana) => new kibana.Plugin({
lifespan: Joi.any().description('This key is handled in the new platform security plugin ONLY'),
}).default(),
secureCookies: Joi.any().description('This key is handled in the new platform security plugin ONLY'),
loginAssistanceMessage: Joi.string().default(),
loginAssistanceMessage: Joi.any().description('This key is handled in the new platform security plugin ONLY'),
authorization: Joi.object({
legacyFallback: Joi.object({
enabled: Joi.boolean().default(true) // deprecated
@ -144,10 +140,6 @@ export const security = (kibana) => new kibana.Plugin({
server.expose({ getUser: request => securityPlugin.authc.getCurrentUser(KibanaRequest.from(request)) });
initAuthenticateApi(securityPlugin, server);
initUsersApi(securityPlugin, server);
initApiKeysApi(server);
initIndicesApi(server);
initLoginView(securityPlugin, server);
initLogoutView(server);
initLoggedOutView(securityPlugin, server);

View file

@ -11,8 +11,8 @@ import 'plugins/security/services/auto_logout';
function isUnauthorizedResponseAllowed(response) {
const API_WHITELIST = [
'/api/security/v1/login',
'/api/security/v1/users/.*/password'
'/internal/security/login',
'/internal/security/users/.*/password'
];
const url = response.config.url;

View file

@ -7,12 +7,12 @@
import { kfetch } from 'ui/kfetch';
import { AuthenticatedUser, Role, User, EditUser } from '../../common/model';
const usersUrl = '/api/security/v1/users';
const usersUrl = '/internal/security/users';
const rolesUrl = '/api/security/role';
export class UserAPIClient {
public async getCurrentUser(): Promise<AuthenticatedUser> {
return await kfetch({ pathname: `/api/security/v1/me` });
return await kfetch({ pathname: `/internal/security/me` });
}
public async getUsers(): Promise<User[]> {

View file

@ -5,8 +5,7 @@
*/
import { kfetch } from 'ui/kfetch';
import { ApiKey, ApiKeyToInvalidate } from '../../common/model/api_key';
import { INTERNAL_API_BASE_PATH } from '../../common/constants';
import { ApiKey, ApiKeyToInvalidate } from '../../common/model';
interface CheckPrivilegesResponse {
areApiKeysEnabled: boolean;
@ -22,7 +21,7 @@ interface GetApiKeysResponse {
apiKeys: ApiKey[];
}
const apiKeysUrl = `${INTERNAL_API_BASE_PATH}/api_key`;
const apiKeysUrl = `/internal/security/api_key`;
export class ApiKeysApi {
public static async checkPrivileges(): Promise<CheckPrivilegesResponse> {

View file

@ -6,7 +6,7 @@
import { IHttpResponse } from 'angular';
import chrome from 'ui/chrome';
const apiBase = chrome.addBasePath(`/api/security/v1/fields`);
const apiBase = chrome.addBasePath(`/internal/security/fields`);
export async function getFields($http: any, query: string): Promise<string[]> {
return await $http

View file

@ -10,7 +10,7 @@ const module = uiModules.get('security', []);
module.service('shieldIndices', ($http, chrome) => {
return {
getFields: (query) => {
return $http.get(chrome.addBasePath(`/api/security/v1/fields/${query}`))
return $http.get(chrome.addBasePath(`/internal/security/fields/${query}`))
.then(response => response.data);
}
};

View file

@ -10,7 +10,7 @@ import { uiModules } from 'ui/modules';
const module = uiModules.get('security', ['ngResource']);
module.service('ShieldUser', ($resource, chrome) => {
const baseUrl = chrome.addBasePath('/api/security/v1/users/:username');
const baseUrl = chrome.addBasePath('/internal/security/users/:username');
const ShieldUser = $resource(baseUrl, {
username: '@username'
}, {
@ -21,7 +21,7 @@ module.service('ShieldUser', ($resource, chrome) => {
},
getCurrent: {
method: 'GET',
url: chrome.addBasePath('/api/security/v1/me')
url: chrome.addBasePath('/internal/security/me')
}
});

View file

@ -190,7 +190,7 @@ class BasicLoginFormUI extends Component<Props, State> {
const { username, password } = this.state;
http.post('./api/security/v1/login', { username, password }).then(
http.post('./internal/security/login', { username, password }).then(
() => (window.location.href = next),
(error: any) => {
const { statusCode = 500 } = error.data || {};

View file

@ -12,5 +12,5 @@ chrome
$window.sessionStorage.clear();
// Redirect user to the server logout endpoint to complete logout.
$window.location.href = chrome.addBasePath(`/api/security/v1/logout${$window.location.search}`);
$window.location.href = chrome.addBasePath(`/api/security/logout${$window.location.search}`);
});

View file

@ -29,7 +29,7 @@ import _ from 'lodash';
import { toastNotifications } from 'ui/notify';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { SectionLoading } from '../../../../../../../../../src/plugins/es_ui_shared/public/components/section_loading';
import { ApiKey, ApiKeyToInvalidate } from '../../../../../common/model/api_key';
import { ApiKey, ApiKeyToInvalidate } from '../../../../../common/model';
import { ApiKeysApi } from '../../../../lib/api_keys_api';
import { PermissionDenied } from './permission_denied';
import { EmptyPrompt } from './empty_prompt';

View file

@ -8,7 +8,7 @@ import React, { Fragment, useRef, useState } from 'react';
import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui';
import { toastNotifications } from 'ui/notify';
import { i18n } from '@kbn/i18n';
import { ApiKeyToInvalidate } from '../../../../../../common/model/api_key';
import { ApiKeyToInvalidate } from '../../../../../../common/model';
import { ApiKeysApi } from '../../../../../lib/api_keys_api';
interface Props {

View file

@ -1,39 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Request } from 'hapi';
import url from 'url';
interface RequestFixtureOptions {
headers?: Record<string, string>;
auth?: string;
params?: Record<string, unknown>;
path?: string;
basePath?: string;
search?: string;
payload?: unknown;
}
export function requestFixture({
headers = { accept: 'something/html' },
auth,
params,
path = '/wat',
search = '',
payload,
}: RequestFixtureOptions = {}) {
return ({
raw: { req: { headers } },
auth,
headers,
params,
url: { path, search },
query: search ? url.parse(search, true /* parseQueryString */).query : {},
payload,
state: { user: 'these are the contents of the user client cookie' },
route: { settings: {} },
} as any) as Request;
}

View file

@ -1,56 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { stub } from 'sinon';
export function serverFixture() {
return {
config: stub(),
register: stub(),
expose: stub(),
log: stub(),
route: stub(),
decorate: stub(),
info: {
protocol: 'protocol',
},
auth: {
strategy: stub(),
test: stub(),
},
plugins: {
elasticsearch: {
createCluster: stub(),
},
kibana: {
systemApi: { isSystemApiRequest: stub() },
},
security: {
getUser: stub(),
authenticate: stub(),
deauthenticate: stub(),
authorization: {
application: stub(),
},
},
xpack_main: {
info: {
isAvailable: stub(),
feature: stub(),
license: {
isOneOf: stub(),
},
},
},
},
};
}

View file

@ -1,18 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
const Boom = require('boom');
export function routePreCheckLicense(server) {
return function forbidApiAccess() {
const licenseCheckResults = server.newPlatform.setup.plugins.security.__legacyCompat.license.getFeatures();
if (!licenseCheckResults.showLinks) {
throw Boom.forbidden(licenseCheckResults.linksMessage);
} else {
return null;
}
};
}

View file

@ -1,17 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Joi from 'joi';
export const userSchema = Joi.object({
username: Joi.string().required(),
password: Joi.string(),
roles: Joi.array().items(Joi.string()),
full_name: Joi.string().allow(null, ''),
email: Joi.string().allow(null, ''),
metadata: Joi.object(),
enabled: Joi.boolean().default(true)
});

View file

@ -1,260 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import Boom from 'boom';
import Joi from 'joi';
import sinon from 'sinon';
import { serverFixture } from '../../../../lib/__tests__/__fixtures__/server';
import { requestFixture } from '../../../../lib/__tests__/__fixtures__/request';
import { AuthenticationResult, DeauthenticationResult } from '../../../../../../../../plugins/security/server';
import { initAuthenticateApi } from '../authenticate';
import { KibanaRequest } from '../../../../../../../../../src/core/server';
describe('Authentication routes', () => {
let serverStub;
let hStub;
let loginStub;
let logoutStub;
beforeEach(() => {
serverStub = serverFixture();
hStub = {
authenticated: sinon.stub(),
continue: 'blah',
redirect: sinon.stub(),
response: sinon.stub()
};
loginStub = sinon.stub();
logoutStub = sinon.stub();
initAuthenticateApi({
authc: { login: loginStub, logout: logoutStub },
__legacyCompat: { config: { authc: { providers: ['basic'] } } },
}, serverStub);
});
describe('login', () => {
let loginRoute;
let request;
beforeEach(() => {
loginRoute = serverStub.route
.withArgs(sinon.match({ path: '/api/security/v1/login' }))
.firstCall
.args[0];
request = requestFixture({
headers: {},
payload: { username: 'user', password: 'password' }
});
});
it('correctly defines route.', async () => {
expect(loginRoute.method).to.be('POST');
expect(loginRoute.path).to.be('/api/security/v1/login');
expect(loginRoute.handler).to.be.a(Function);
expect(loginRoute.config).to.eql({
auth: false,
validate: {
payload: Joi.object({
username: Joi.string().required(),
password: Joi.string().required()
})
},
response: {
emptyStatusCode: 204,
}
});
});
it('returns 500 if authentication throws unhandled exception.', async () => {
const unhandledException = new Error('Something went wrong.');
loginStub.throws(unhandledException);
return loginRoute
.handler(request, hStub)
.catch((response) => {
expect(response.isBoom).to.be(true);
expect(response.output.payload).to.eql({
statusCode: 500,
error: 'Internal Server Error',
message: 'An internal server error occurred'
});
});
});
it('returns 401 if authentication fails.', async () => {
const failureReason = new Error('Something went wrong.');
loginStub.resolves(AuthenticationResult.failed(failureReason));
return loginRoute
.handler(request, hStub)
.catch((response) => {
expect(response.isBoom).to.be(true);
expect(response.message).to.be(failureReason.message);
expect(response.output.statusCode).to.be(401);
});
});
it('returns 401 if authentication is not handled.', async () => {
loginStub.resolves(AuthenticationResult.notHandled());
return loginRoute
.handler(request, hStub)
.catch((response) => {
expect(response.isBoom).to.be(true);
expect(response.message).to.be('Unauthorized');
expect(response.output.statusCode).to.be(401);
});
});
describe('authentication succeeds', () => {
it(`returns user data`, async () => {
loginStub.resolves(AuthenticationResult.succeeded({ username: 'user' }));
await loginRoute.handler(request, hStub);
sinon.assert.calledOnce(hStub.response);
sinon.assert.calledOnce(loginStub);
sinon.assert.calledWithExactly(
loginStub,
sinon.match.instanceOf(KibanaRequest),
{ provider: 'basic', value: { username: 'user', password: 'password' } }
);
});
});
});
describe('logout', () => {
let logoutRoute;
beforeEach(() => {
serverStub.config.returns({
get: sinon.stub().withArgs('server.basePath').returns('/test-base-path')
});
logoutRoute = serverStub.route
.withArgs(sinon.match({ path: '/api/security/v1/logout' }))
.firstCall
.args[0];
});
it('correctly defines route.', async () => {
expect(logoutRoute.method).to.be('GET');
expect(logoutRoute.path).to.be('/api/security/v1/logout');
expect(logoutRoute.handler).to.be.a(Function);
expect(logoutRoute.config).to.eql({ auth: false });
});
it('returns 500 if deauthentication throws unhandled exception.', async () => {
const request = requestFixture();
const unhandledException = new Error('Something went wrong.');
logoutStub.rejects(unhandledException);
return logoutRoute
.handler(request, hStub)
.catch((response) => {
expect(response).to.be(Boom.boomify(unhandledException));
sinon.assert.notCalled(hStub.redirect);
});
});
it('returns 500 if authenticator fails to logout.', async () => {
const request = requestFixture();
const failureReason = Boom.forbidden();
logoutStub.resolves(DeauthenticationResult.failed(failureReason));
return logoutRoute
.handler(request, hStub)
.catch((response) => {
expect(response).to.be(Boom.boomify(failureReason));
sinon.assert.notCalled(hStub.redirect);
sinon.assert.calledOnce(logoutStub);
sinon.assert.calledWithExactly(
logoutStub,
sinon.match.instanceOf(KibanaRequest)
);
});
});
it('returns 400 for AJAX requests that can not handle redirect.', async () => {
const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } });
return logoutRoute
.handler(request, hStub)
.catch((response) => {
expect(response.isBoom).to.be(true);
expect(response.message).to.be('Client should be able to process redirect response.');
expect(response.output.statusCode).to.be(400);
sinon.assert.notCalled(hStub.redirect);
});
});
it('redirects user to the URL returned by authenticator.', async () => {
const request = requestFixture();
logoutStub.resolves(DeauthenticationResult.redirectTo('https://custom.logout'));
await logoutRoute.handler(request, hStub);
sinon.assert.calledOnce(hStub.redirect);
sinon.assert.calledWithExactly(hStub.redirect, 'https://custom.logout');
});
it('redirects user to the base path if deauthentication succeeds.', async () => {
const request = requestFixture();
logoutStub.resolves(DeauthenticationResult.succeeded());
await logoutRoute.handler(request, hStub);
sinon.assert.calledOnce(hStub.redirect);
sinon.assert.calledWithExactly(hStub.redirect, '/test-base-path/');
});
it('redirects user to the base path if deauthentication is not handled.', async () => {
const request = requestFixture();
logoutStub.resolves(DeauthenticationResult.notHandled());
await logoutRoute.handler(request, hStub);
sinon.assert.calledOnce(hStub.redirect);
sinon.assert.calledWithExactly(hStub.redirect, '/test-base-path/');
});
});
describe('me', () => {
let meRoute;
beforeEach(() => {
meRoute = serverStub.route
.withArgs(sinon.match({ path: '/api/security/v1/me' }))
.firstCall
.args[0];
});
it('correctly defines route.', async () => {
expect(meRoute.method).to.be('GET');
expect(meRoute.path).to.be('/api/security/v1/me');
expect(meRoute.handler).to.be.a(Function);
expect(meRoute.config).to.be(undefined);
});
it('returns user from the authenticated request property.', async () => {
const request = { auth: { credentials: { username: 'user' } } };
const response = await meRoute.handler(request, hStub);
expect(response).to.eql({ username: 'user' });
});
});
});

View file

@ -1,214 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import Joi from 'joi';
import sinon from 'sinon';
import { serverFixture } from '../../../../lib/__tests__/__fixtures__/server';
import { requestFixture } from '../../../../lib/__tests__/__fixtures__/request';
import { AuthenticationResult } from '../../../../../../../../plugins/security/server';
import { initUsersApi } from '../users';
import * as ClientShield from '../../../../../../../server/lib/get_client_shield';
import { KibanaRequest } from '../../../../../../../../../src/core/server';
describe('User routes', () => {
const sandbox = sinon.createSandbox();
let clusterStub;
let serverStub;
let loginStub;
beforeEach(() => {
serverStub = serverFixture();
loginStub = sinon.stub();
// Cluster is returned by `getClient` function that is wrapped into `once` making cluster
// a static singleton, so we should use sandbox to set/reset its behavior between tests.
clusterStub = sinon.stub({ callWithRequest() {} });
sandbox.stub(ClientShield, 'getClient').returns(clusterStub);
initUsersApi({ authc: { login: loginStub }, __legacyCompat: { config: { authc: { providers: ['basic'] } } } }, serverStub);
});
afterEach(() => sandbox.restore());
describe('change password', () => {
let changePasswordRoute;
let request;
beforeEach(() => {
changePasswordRoute = serverStub.route
.withArgs(sinon.match({ path: '/api/security/v1/users/{username}/password' }))
.firstCall
.args[0];
request = requestFixture({
headers: {},
auth: { credentials: { username: 'user' } },
params: { username: 'target-user' },
payload: { password: 'old-password', newPassword: 'new-password' }
});
});
it('correctly defines route.', async () => {
expect(changePasswordRoute.method).to.be('POST');
expect(changePasswordRoute.path).to.be('/api/security/v1/users/{username}/password');
expect(changePasswordRoute.handler).to.be.a(Function);
expect(changePasswordRoute.config).to.not.have.property('auth');
expect(changePasswordRoute.config).to.have.property('pre');
expect(changePasswordRoute.config.pre).to.have.length(1);
expect(changePasswordRoute.config.validate).to.eql({
payload: Joi.object({
password: Joi.string(),
newPassword: Joi.string().required()
})
});
});
describe('own password', () => {
beforeEach(() => {
request.params.username = request.auth.credentials.username;
loginStub = loginStub
.withArgs(
sinon.match.instanceOf(KibanaRequest),
{ provider: 'basic', value: { username: 'user', password: 'old-password' }, stateless: true }
)
.resolves(AuthenticationResult.succeeded({}));
});
it('returns 403 if old password is wrong.', async () => {
loginStub.resolves(AuthenticationResult.failed(new Error('Something went wrong.')));
const response = await changePasswordRoute.handler(request);
sinon.assert.notCalled(clusterStub.callWithRequest);
expect(response.isBoom).to.be(true);
expect(response.output.payload).to.eql({
statusCode: 403,
error: 'Forbidden',
message: 'Something went wrong.'
});
});
it(`returns 401 if user can't authenticate with new password.`, async () => {
loginStub
.withArgs(
sinon.match.instanceOf(KibanaRequest),
{ provider: 'basic', value: { username: 'user', password: 'new-password' } }
)
.resolves(AuthenticationResult.failed(new Error('Something went wrong.')));
const response = await changePasswordRoute.handler(request);
sinon.assert.calledOnce(clusterStub.callWithRequest);
sinon.assert.calledWithExactly(
clusterStub.callWithRequest,
sinon.match.same(request),
'shield.changePassword',
{ username: 'user', body: { password: 'new-password' } }
);
expect(response.isBoom).to.be(true);
expect(response.output.payload).to.eql({
statusCode: 401,
error: 'Unauthorized',
message: 'Something went wrong.'
});
});
it('returns 500 if password update request fails.', async () => {
clusterStub.callWithRequest
.withArgs(
sinon.match.same(request),
'shield.changePassword',
{ username: 'user', body: { password: 'new-password' } }
)
.rejects(new Error('Request failed.'));
const response = await changePasswordRoute.handler(request);
expect(response.isBoom).to.be(true);
expect(response.output.payload).to.eql({
statusCode: 500,
error: 'Internal Server Error',
message: 'An internal server error occurred'
});
});
it('successfully changes own password if provided old password is correct.', async () => {
loginStub
.withArgs(
sinon.match.instanceOf(KibanaRequest),
{ provider: 'basic', value: { username: 'user', password: 'new-password' } }
)
.resolves(AuthenticationResult.succeeded({}));
const hResponseStub = { code: sinon.stub() };
const hStub = { response: sinon.stub().returns(hResponseStub) };
await changePasswordRoute.handler(request, hStub);
sinon.assert.calledOnce(clusterStub.callWithRequest);
sinon.assert.calledWithExactly(
clusterStub.callWithRequest,
sinon.match.same(request),
'shield.changePassword',
{ username: 'user', body: { password: 'new-password' } }
);
sinon.assert.calledWithExactly(hStub.response);
sinon.assert.calledWithExactly(hResponseStub.code, 204);
});
});
describe('other user password', () => {
it('returns 500 if password update request fails.', async () => {
clusterStub.callWithRequest
.withArgs(
sinon.match.same(request),
'shield.changePassword',
{ username: 'target-user', body: { password: 'new-password' } }
)
.returns(Promise.reject(new Error('Request failed.')));
const response = await changePasswordRoute.handler(request);
sinon.assert.notCalled(serverStub.plugins.security.getUser);
sinon.assert.notCalled(loginStub);
expect(response.isBoom).to.be(true);
expect(response.output.payload).to.eql({
statusCode: 500,
error: 'Internal Server Error',
message: 'An internal server error occurred'
});
});
it('successfully changes user password.', async () => {
const hResponseStub = { code: sinon.stub() };
const hStub = { response: sinon.stub().returns(hResponseStub) };
await changePasswordRoute.handler(request, hStub);
sinon.assert.notCalled(serverStub.plugins.security.getUser);
sinon.assert.notCalled(loginStub);
sinon.assert.calledOnce(clusterStub.callWithRequest);
sinon.assert.calledWithExactly(
clusterStub.callWithRequest,
sinon.match.same(request),
'shield.changePassword',
{ username: 'target-user', body: { password: 'new-password' } }
);
sinon.assert.calledWithExactly(hStub.response);
sinon.assert.calledWithExactly(hResponseStub.code, 204);
});
});
});
});

View file

@ -1,45 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Joi from 'joi';
import { wrapError } from '../../../../../../../../plugins/security/server';
import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants';
export function initGetApiKeysApi(server, callWithRequest, routePreCheckLicenseFn) {
server.route({
method: 'GET',
path: `${INTERNAL_API_BASE_PATH}/api_key`,
async handler(request) {
try {
const { isAdmin } = request.query;
const result = await callWithRequest(
request,
'shield.getAPIKeys',
{
owner: !isAdmin
}
);
const validKeys = result.api_keys.filter(({ invalidated }) => !invalidated);
return {
apiKeys: validKeys,
};
} catch (error) {
return wrapError(error);
}
},
config: {
pre: [routePreCheckLicenseFn],
validate: {
query: Joi.object().keys({
isAdmin: Joi.bool().required(),
}).required(),
},
}
});
}

View file

@ -1,166 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Hapi from 'hapi';
import Boom from 'boom';
import { initGetApiKeysApi } from './get';
import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants';
const createMockServer = () => new Hapi.Server({ debug: false, port: 8080 });
describe('GET API keys', () => {
const getApiKeysTest = (
description,
{
preCheckLicenseImpl = () => null,
callWithRequestImpl,
asserts,
isAdmin = true,
}
) => {
test(description, async () => {
const mockServer = createMockServer();
const pre = jest.fn().mockImplementation(preCheckLicenseImpl);
const mockCallWithRequest = jest.fn();
if (callWithRequestImpl) {
mockCallWithRequest.mockImplementation(callWithRequestImpl);
}
initGetApiKeysApi(mockServer, mockCallWithRequest, pre);
const headers = {
authorization: 'foo',
};
const request = {
method: 'GET',
url: `${INTERNAL_API_BASE_PATH}/api_key?isAdmin=${isAdmin}`,
headers,
};
const { result, statusCode } = await mockServer.inject(request);
expect(pre).toHaveBeenCalled();
if (callWithRequestImpl) {
expect(mockCallWithRequest).toHaveBeenCalledWith(
expect.objectContaining({
headers: expect.objectContaining({
authorization: headers.authorization,
}),
}),
'shield.getAPIKeys',
{
owner: !isAdmin,
},
);
} else {
expect(mockCallWithRequest).not.toHaveBeenCalled();
}
expect(statusCode).toBe(asserts.statusCode);
expect(result).toEqual(asserts.result);
});
};
describe('failure', () => {
getApiKeysTest('returns result of routePreCheckLicense', {
preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'),
asserts: {
statusCode: 403,
result: {
error: 'Forbidden',
statusCode: 403,
message: 'test forbidden message',
},
},
});
getApiKeysTest('returns error from callWithRequest', {
callWithRequestImpl: async () => {
throw Boom.notAcceptable('test not acceptable message');
},
asserts: {
statusCode: 406,
result: {
error: 'Not Acceptable',
statusCode: 406,
message: 'test not acceptable message',
},
},
});
});
describe('success', () => {
getApiKeysTest('returns API keys', {
callWithRequestImpl: async () => ({
api_keys:
[{
id: 'YCLV7m0BJ3xI4hhWB648',
name: 'test-api-key',
creation: 1571670001452,
expiration: 1571756401452,
invalidated: false,
username: 'elastic',
realm: 'reserved'
}]
}),
asserts: {
statusCode: 200,
result: {
apiKeys:
[{
id: 'YCLV7m0BJ3xI4hhWB648',
name: 'test-api-key',
creation: 1571670001452,
expiration: 1571756401452,
invalidated: false,
username: 'elastic',
realm: 'reserved'
}]
},
},
});
getApiKeysTest('returns only valid API keys', {
callWithRequestImpl: async () => ({
api_keys:
[{
id: 'YCLV7m0BJ3xI4hhWB648',
name: 'test-api-key1',
creation: 1571670001452,
expiration: 1571756401452,
invalidated: true,
username: 'elastic',
realm: 'reserved'
}, {
id: 'YCLV7m0BJ3xI4hhWB648',
name: 'test-api-key2',
creation: 1571670001452,
expiration: 1571756401452,
invalidated: false,
username: 'elastic',
realm: 'reserved'
}],
}),
asserts: {
statusCode: 200,
result: {
apiKeys:
[{
id: 'YCLV7m0BJ3xI4hhWB648',
name: 'test-api-key2',
creation: 1571670001452,
expiration: 1571756401452,
invalidated: false,
username: 'elastic',
realm: 'reserved'
}]
},
},
});
});
});

View file

@ -1,20 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getClient } from '../../../../../../../server/lib/get_client_shield';
import { routePreCheckLicense } from '../../../../lib/route_pre_check_license';
import { initCheckPrivilegesApi } from './privileges';
import { initGetApiKeysApi } from './get';
import { initInvalidateApiKeysApi } from './invalidate';
export function initApiKeysApi(server) {
const callWithRequest = getClient(server).callWithRequest;
const routePreCheckLicenseFn = routePreCheckLicense(server);
initCheckPrivilegesApi(server, callWithRequest, routePreCheckLicenseFn);
initGetApiKeysApi(server, callWithRequest, routePreCheckLicenseFn);
initInvalidateApiKeysApi(server, callWithRequest, routePreCheckLicenseFn);
}

View file

@ -1,70 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Joi from 'joi';
import { wrapError } from '../../../../../../../../plugins/security/server';
import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants';
export function initInvalidateApiKeysApi(server, callWithRequest, routePreCheckLicenseFn) {
server.route({
method: 'POST',
path: `${INTERNAL_API_BASE_PATH}/api_key/invalidate`,
async handler(request) {
try {
const { apiKeys, isAdmin } = request.payload;
const itemsInvalidated = [];
const errors = [];
// Send the request to invalidate the API key and return an error if it could not be deleted.
const sendRequestToInvalidateApiKey = async (id) => {
try {
const body = { id };
if (!isAdmin) {
body.owner = true;
}
await callWithRequest(request, 'shield.invalidateAPIKey', { body });
return null;
} catch (error) {
return wrapError(error);
}
};
const invalidateApiKey = async ({ id, name }) => {
const error = await sendRequestToInvalidateApiKey(id);
if (error) {
errors.push({ id, name, error });
} else {
itemsInvalidated.push({ id, name });
}
};
// Invalidate all API keys in parallel.
await Promise.all(apiKeys.map((key) => invalidateApiKey(key)));
return {
itemsInvalidated,
errors,
};
} catch (error) {
return wrapError(error);
}
},
config: {
pre: [routePreCheckLicenseFn],
validate: {
payload: Joi.object({
apiKeys: Joi.array().items(Joi.object({
id: Joi.string().required(),
name: Joi.string().required(),
})).required(),
isAdmin: Joi.bool().required(),
})
},
}
});
}

View file

@ -1,200 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Hapi from 'hapi';
import Boom from 'boom';
import { initInvalidateApiKeysApi } from './invalidate';
import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants';
const createMockServer = () => new Hapi.Server({ debug: false, port: 8080 });
describe('POST invalidate', () => {
const postInvalidateTest = (
description,
{
preCheckLicenseImpl = () => null,
callWithRequestImpls = [],
asserts,
payload,
}
) => {
test(description, async () => {
const mockServer = createMockServer();
const pre = jest.fn().mockImplementation(preCheckLicenseImpl);
const mockCallWithRequest = jest.fn();
for (const impl of callWithRequestImpls) {
mockCallWithRequest.mockImplementationOnce(impl);
}
initInvalidateApiKeysApi(mockServer, mockCallWithRequest, pre);
const headers = {
authorization: 'foo',
};
const request = {
method: 'POST',
url: `${INTERNAL_API_BASE_PATH}/api_key/invalidate`,
headers,
payload,
};
const { result, statusCode } = await mockServer.inject(request);
expect(pre).toHaveBeenCalled();
if (asserts.callWithRequests) {
for (const args of asserts.callWithRequests) {
expect(mockCallWithRequest).toHaveBeenCalledWith(
expect.objectContaining({
headers: expect.objectContaining({
authorization: headers.authorization,
}),
}),
...args
);
}
} else {
expect(mockCallWithRequest).not.toHaveBeenCalled();
}
expect(statusCode).toBe(asserts.statusCode);
expect(result).toEqual(asserts.result);
});
};
describe('failure', () => {
postInvalidateTest('returns result of routePreCheckLicense', {
preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'),
payload: {
apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }],
isAdmin: true
},
asserts: {
statusCode: 403,
result: {
error: 'Forbidden',
statusCode: 403,
message: 'test forbidden message',
},
},
});
postInvalidateTest('returns errors array from callWithRequest', {
callWithRequestImpls: [async () => {
throw Boom.notAcceptable('test not acceptable message');
}],
payload: {
apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key', }],
isAdmin: true
},
asserts: {
callWithRequests: [
['shield.invalidateAPIKey', {
body: {
id: 'si8If24B1bKsmSLTAhJV',
},
}],
],
statusCode: 200,
result: {
itemsInvalidated: [],
errors: [{
id: 'si8If24B1bKsmSLTAhJV',
name: 'my-api-key',
error: Boom.notAcceptable('test not acceptable message'),
}]
},
},
});
});
describe('success', () => {
postInvalidateTest('invalidates API keys', {
callWithRequestImpls: [async () => null],
payload: {
apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key', }],
isAdmin: true
},
asserts: {
callWithRequests: [
['shield.invalidateAPIKey', {
body: {
id: 'si8If24B1bKsmSLTAhJV',
},
}],
],
statusCode: 200,
result: {
itemsInvalidated: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key', }],
errors: [],
},
},
});
postInvalidateTest('adds "owner" to body if isAdmin=false', {
callWithRequestImpls: [async () => null],
payload: {
apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key', }],
isAdmin: false
},
asserts: {
callWithRequests: [
['shield.invalidateAPIKey', {
body: {
id: 'si8If24B1bKsmSLTAhJV',
owner: true,
},
}],
],
statusCode: 200,
result: {
itemsInvalidated: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }],
errors: [],
},
},
});
postInvalidateTest('returns only successful invalidation requests', {
callWithRequestImpls: [
async () => null,
async () => {
throw Boom.notAcceptable('test not acceptable message');
}],
payload: {
apiKeys: [
{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key1' },
{ id: 'ab8If24B1bKsmSLTAhNC', name: 'my-api-key2' }
],
isAdmin: true
},
asserts: {
callWithRequests: [
['shield.invalidateAPIKey', {
body: {
id: 'si8If24B1bKsmSLTAhJV',
},
}],
['shield.invalidateAPIKey', {
body: {
id: 'ab8If24B1bKsmSLTAhNC',
},
}],
],
statusCode: 200,
result: {
itemsInvalidated: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key1' }],
errors: [{
id: 'ab8If24B1bKsmSLTAhNC',
name: 'my-api-key2',
error: Boom.notAcceptable('test not acceptable message'),
}]
},
},
});
});
});

View file

@ -1,75 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { wrapError } from '../../../../../../../../plugins/security/server';
import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants';
export function initCheckPrivilegesApi(server, callWithRequest, routePreCheckLicenseFn) {
server.route({
method: 'GET',
path: `${INTERNAL_API_BASE_PATH}/api_key/privileges`,
async handler(request) {
try {
const result = await Promise.all([
callWithRequest(
request,
'shield.hasPrivileges',
{
body: {
cluster: [
'manage_security',
'manage_api_key',
],
},
}
),
new Promise(async (resolve, reject) => {
try {
const result = await callWithRequest(
request,
'shield.getAPIKeys',
{
owner: true
}
);
// If the API returns a truthy result that means it's enabled.
resolve({ areApiKeysEnabled: !!result });
} catch (e) {
// This is a brittle dependency upon message. Tracked by https://github.com/elastic/elasticsearch/issues/47759.
if (e.message.includes('api keys are not enabled')) {
return resolve({ areApiKeysEnabled: false });
}
// It's a real error, so rethrow it.
reject(e);
}
}),
]);
const [{
cluster: {
manage_security: manageSecurity,
manage_api_key: manageApiKey,
}
}, {
areApiKeysEnabled,
}] = result;
const isAdmin = manageSecurity || manageApiKey;
return {
areApiKeysEnabled,
isAdmin,
};
} catch (error) {
return wrapError(error);
}
},
config: {
pre: [routePreCheckLicenseFn]
}
});
}

View file

@ -1,254 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Hapi from 'hapi';
import Boom from 'boom';
import { initCheckPrivilegesApi } from './privileges';
import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants';
const createMockServer = () => new Hapi.Server({ debug: false, port: 8080 });
describe('GET privileges', () => {
const getPrivilegesTest = (
description,
{
preCheckLicenseImpl = () => null,
callWithRequestImpls = [],
asserts,
}
) => {
test(description, async () => {
const mockServer = createMockServer();
const pre = jest.fn().mockImplementation(preCheckLicenseImpl);
const mockCallWithRequest = jest.fn();
for (const impl of callWithRequestImpls) {
mockCallWithRequest.mockImplementationOnce(impl);
}
initCheckPrivilegesApi(mockServer, mockCallWithRequest, pre);
const headers = {
authorization: 'foo',
};
const request = {
method: 'GET',
url: `${INTERNAL_API_BASE_PATH}/api_key/privileges`,
headers,
};
const { result, statusCode } = await mockServer.inject(request);
expect(pre).toHaveBeenCalled();
if (asserts.callWithRequests) {
for (const args of asserts.callWithRequests) {
expect(mockCallWithRequest).toHaveBeenCalledWith(
expect.objectContaining({
headers: expect.objectContaining({
authorization: headers.authorization,
}),
}),
...args
);
}
} else {
expect(mockCallWithRequest).not.toHaveBeenCalled();
}
expect(statusCode).toBe(asserts.statusCode);
expect(result).toEqual(asserts.result);
});
};
describe('failure', () => {
getPrivilegesTest('returns result of routePreCheckLicense', {
preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'),
asserts: {
statusCode: 403,
result: {
error: 'Forbidden',
statusCode: 403,
message: 'test forbidden message',
},
},
});
getPrivilegesTest('returns error from first callWithRequest', {
callWithRequestImpls: [async () => {
throw Boom.notAcceptable('test not acceptable message');
}, async () => { }],
asserts: {
callWithRequests: [
['shield.hasPrivileges', {
body: {
cluster: [
'manage_security',
'manage_api_key',
],
},
}],
['shield.getAPIKeys', { owner: true }],
],
statusCode: 406,
result: {
error: 'Not Acceptable',
statusCode: 406,
message: 'test not acceptable message',
},
},
});
getPrivilegesTest('returns error from second callWithRequest', {
callWithRequestImpls: [async () => { }, async () => {
throw Boom.notAcceptable('test not acceptable message');
}],
asserts: {
callWithRequests: [
['shield.hasPrivileges', {
body: {
cluster: [
'manage_security',
'manage_api_key',
],
},
}],
['shield.getAPIKeys', { owner: true }],
],
statusCode: 406,
result: {
error: 'Not Acceptable',
statusCode: 406,
message: 'test not acceptable message',
},
},
});
});
describe('success', () => {
getPrivilegesTest('returns areApiKeysEnabled and isAdmin', {
callWithRequestImpls: [
async () => ({
username: 'elastic',
has_all_requested: true,
cluster: { manage_api_key: true, manage_security: true },
index: {},
application: {}
}),
async () => (
{
api_keys:
[{
id: 'si8If24B1bKsmSLTAhJV',
name: 'my-api-key',
creation: 1574089261632,
expiration: 1574175661632,
invalidated: false,
username: 'elastic',
realm: 'reserved'
}]
}
),
],
asserts: {
callWithRequests: [
['shield.getAPIKeys', { owner: true }],
['shield.hasPrivileges', {
body: {
cluster: [
'manage_security',
'manage_api_key',
],
},
}],
],
statusCode: 200,
result: {
areApiKeysEnabled: true,
isAdmin: true,
},
},
});
getPrivilegesTest('returns areApiKeysEnabled=false when getAPIKeys error message includes "api keys are not enabled"', {
callWithRequestImpls: [
async () => ({
username: 'elastic',
has_all_requested: true,
cluster: { manage_api_key: true, manage_security: true },
index: {},
application: {}
}),
async () => {
throw Boom.unauthorized('api keys are not enabled');
},
],
asserts: {
callWithRequests: [
['shield.getAPIKeys', { owner: true }],
['shield.hasPrivileges', {
body: {
cluster: [
'manage_security',
'manage_api_key',
],
},
}],
],
statusCode: 200,
result: {
areApiKeysEnabled: false,
isAdmin: true,
},
},
});
getPrivilegesTest('returns isAdmin=false when user has insufficient privileges', {
callWithRequestImpls: [
async () => ({
username: 'elastic',
has_all_requested: true,
cluster: { manage_api_key: false, manage_security: false },
index: {},
application: {}
}),
async () => (
{
api_keys:
[{
id: 'si8If24B1bKsmSLTAhJV',
name: 'my-api-key',
creation: 1574089261632,
expiration: 1574175661632,
invalidated: false,
username: 'elastic',
realm: 'reserved'
}]
}
),
],
asserts: {
callWithRequests: [
['shield.getAPIKeys', { owner: true }],
['shield.hasPrivileges', {
body: {
cluster: [
'manage_security',
'manage_api_key',
],
},
}],
],
statusCode: 200,
result: {
areApiKeysEnabled: true,
isAdmin: false,
},
},
});
});
});

View file

@ -1,227 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import Joi from 'joi';
import { schema } from '@kbn/config-schema';
import { canRedirectRequest, wrapError, OIDCAuthenticationFlow } from '../../../../../../../plugins/security/server';
import { KibanaRequest } from '../../../../../../../../src/core/server';
import { createCSPRuleString } from '../../../../../../../../src/legacy/server/csp';
export function initAuthenticateApi({ authc: { login, logout }, __legacyCompat: { config } }, server) {
function prepareCustomResourceResponse(response, contentType) {
return response
.header('cache-control', 'private, no-cache, no-store')
.header('content-security-policy', createCSPRuleString(server.config().get('csp.rules')))
.type(contentType);
}
server.route({
method: 'POST',
path: '/api/security/v1/login',
config: {
auth: false,
validate: {
payload: Joi.object({
username: Joi.string().required(),
password: Joi.string().required()
})
},
response: {
emptyStatusCode: 204,
}
},
async handler(request, h) {
const { username, password } = request.payload;
try {
// We should prefer `token` over `basic` if possible.
const providerToLoginWith = config.authc.providers.includes('token')
? 'token'
: 'basic';
const authenticationResult = await login(KibanaRequest.from(request), {
provider: providerToLoginWith,
value: { username, password }
});
if (!authenticationResult.succeeded()) {
throw Boom.unauthorized(authenticationResult.error);
}
return h.response();
} catch(err) {
throw wrapError(err);
}
}
});
/**
* The route should be configured as a redirect URI in OP when OpenID Connect implicit flow
* is used, so that we can extract authentication response from URL fragment and send it to
* the `/api/security/v1/oidc` route.
*/
server.route({
method: 'GET',
path: '/api/security/v1/oidc/implicit',
config: { auth: false },
async handler(request, h) {
return prepareCustomResourceResponse(
h.response(`
<!DOCTYPE html>
<title>Kibana OpenID Connect Login</title>
<script src="${server.config().get('server.basePath')}/api/security/v1/oidc/implicit.js"></script>
`),
'text/html'
);
}
});
/**
* The route that accompanies `/api/security/v1/oidc/implicit` and renders a JavaScript snippet
* that extracts fragment part from the URL and send it to the `/api/security/v1/oidc` route.
* We need this separate endpoint because of default CSP policy that forbids inline scripts.
*/
server.route({
method: 'GET',
path: '/api/security/v1/oidc/implicit.js',
config: { auth: false },
async handler(request, h) {
return prepareCustomResourceResponse(
h.response(`
window.location.replace(
'${server.config().get('server.basePath')}/api/security/v1/oidc?authenticationResponseURI=' +
encodeURIComponent(window.location.href)
);
`),
'text/javascript'
);
}
});
server.route({
// POST is only allowed for Third Party initiated authentication
// Consider splitting this route into two (GET and POST) when it's migrated to New Platform.
method: ['GET', 'POST'],
path: '/api/security/v1/oidc',
config: {
auth: false,
validate: {
query: Joi.object().keys({
iss: Joi.string().uri({ scheme: 'https' }),
login_hint: Joi.string(),
target_link_uri: Joi.string().uri(),
code: Joi.string(),
error: Joi.string(),
error_description: Joi.string(),
error_uri: Joi.string().uri(),
state: Joi.string(),
authenticationResponseURI: Joi.string(),
}).unknown(),
}
},
async handler(request, h) {
try {
const query = request.query || {};
const payload = request.payload || {};
// An HTTP GET request with a query parameter named `authenticationResponseURI` that includes URL fragment OpenID
// Connect Provider sent during implicit authentication flow to the Kibana own proxy page that extracted that URL
// fragment and put it into `authenticationResponseURI` query string parameter for this endpoint. See more details
// at https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth
let loginAttempt;
if (query.authenticationResponseURI) {
loginAttempt = {
flow: OIDCAuthenticationFlow.Implicit,
authenticationResponseURI: query.authenticationResponseURI,
};
} else if (query.code || query.error) {
// An HTTP GET request with a query parameter named `code` (or `error`) as the response to a successful (or
// failed) authentication from an OpenID Connect Provider during authorization code authentication flow.
// See more details at https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth.
loginAttempt = {
flow: OIDCAuthenticationFlow.AuthorizationCode,
// We pass the path only as we can't be sure of the full URL and Elasticsearch doesn't need it anyway.
authenticationResponseURI: request.url.path,
};
} else if (query.iss || payload.iss) {
// An HTTP GET request with a query parameter named `iss` or an HTTP POST request with the same parameter in the
// payload as part of a 3rd party initiated authentication. See more details at
// https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin
loginAttempt = {
flow: OIDCAuthenticationFlow.InitiatedBy3rdParty,
iss: query.iss || payload.iss,
loginHint: query.login_hint || payload.login_hint,
};
}
if (!loginAttempt) {
throw Boom.badRequest('Unrecognized login attempt.');
}
// We handle the fact that the user might get redirected to Kibana while already having an session
// Return an error notifying the user they are already logged in.
const authenticationResult = await login(KibanaRequest.from(request), {
provider: 'oidc',
value: loginAttempt
});
if (authenticationResult.succeeded()) {
return Boom.forbidden(
'Sorry, you already have an active Kibana session. ' +
'If you want to start a new one, please logout from the existing session first.'
);
}
if (authenticationResult.redirected()) {
return h.redirect(authenticationResult.redirectURL);
}
throw Boom.unauthorized(authenticationResult.error);
} catch (err) {
throw wrapError(err);
}
}
});
server.route({
method: 'GET',
path: '/api/security/v1/logout',
config: {
auth: false
},
async handler(request, h) {
if (!canRedirectRequest(KibanaRequest.from(request))) {
throw Boom.badRequest('Client should be able to process redirect response.');
}
try {
const deauthenticationResult = await logout(
// Allow unknown query parameters as this endpoint can be hit by the 3rd-party with any
// set of query string parameters (e.g. SAML/OIDC logout request parameters).
KibanaRequest.from(request, {
query: schema.object({}, { allowUnknowns: true }),
})
);
if (deauthenticationResult.failed()) {
throw wrapError(deauthenticationResult.error);
}
return h.redirect(
deauthenticationResult.redirectURL || `${server.config().get('server.basePath')}/`
);
} catch (err) {
throw wrapError(err);
}
}
});
server.route({
method: 'GET',
path: '/api/security/v1/me',
handler(request) {
return request.auth.credentials;
}
});
}

View file

@ -1,36 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import { getClient } from '../../../../../../server/lib/get_client_shield';
import { wrapError } from '../../../../../../../plugins/security/server';
export function initIndicesApi(server) {
const callWithRequest = getClient(server).callWithRequest;
server.route({
method: 'GET',
path: '/api/security/v1/fields/{query}',
handler(request) {
return callWithRequest(request, 'indices.getFieldMapping', {
index: request.params.query,
fields: '*',
allowNoIndices: false,
includeDefaults: true
})
.then((mappings) =>
_(mappings)
.map('mappings')
.flatten()
.map(_.keys)
.flatten()
.uniq()
.value()
)
.catch(wrapError);
}
});
}

View file

@ -1,142 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import Boom from 'boom';
import Joi from 'joi';
import { getClient } from '../../../../../../server/lib/get_client_shield';
import { userSchema } from '../../../lib/user_schema';
import { routePreCheckLicense } from '../../../lib/route_pre_check_license';
import { wrapError } from '../../../../../../../plugins/security/server';
import { KibanaRequest } from '../../../../../../../../src/core/server';
export function initUsersApi({ authc: { login }, __legacyCompat: { config } }, server) {
const callWithRequest = getClient(server).callWithRequest;
const routePreCheckLicenseFn = routePreCheckLicense(server);
server.route({
method: 'GET',
path: '/api/security/v1/users',
handler(request) {
return callWithRequest(request, 'shield.getUser').then(
_.values,
wrapError
);
},
config: {
pre: [routePreCheckLicenseFn]
}
});
server.route({
method: 'GET',
path: '/api/security/v1/users/{username}',
handler(request) {
const username = request.params.username;
return callWithRequest(request, 'shield.getUser', { username }).then(
(response) => {
if (response[username]) return response[username];
throw Boom.notFound();
},
wrapError);
},
config: {
pre: [routePreCheckLicenseFn]
}
});
server.route({
method: 'POST',
path: '/api/security/v1/users/{username}',
handler(request) {
const username = request.params.username;
const body = _(request.payload).omit(['username', 'enabled']).omit(_.isNull);
return callWithRequest(request, 'shield.putUser', { username, body }).then(
() => request.payload,
wrapError);
},
config: {
validate: {
payload: userSchema
},
pre: [routePreCheckLicenseFn]
}
});
server.route({
method: 'DELETE',
path: '/api/security/v1/users/{username}',
handler(request, h) {
const username = request.params.username;
return callWithRequest(request, 'shield.deleteUser', { username }).then(
() => h.response().code(204),
wrapError);
},
config: {
pre: [routePreCheckLicenseFn]
}
});
server.route({
method: 'POST',
path: '/api/security/v1/users/{username}/password',
async handler(request, h) {
const username = request.params.username;
const { password, newPassword } = request.payload;
const isCurrentUser = username === request.auth.credentials.username;
// We should prefer `token` over `basic` if possible.
const providerToLoginWith = config.authc.providers.includes('token')
? 'token'
: 'basic';
// If user tries to change own password, let's check if old password is valid first by trying
// to login.
if (isCurrentUser) {
const authenticationResult = await login(KibanaRequest.from(request), {
provider: providerToLoginWith,
value: { username, password },
// We shouldn't alter authentication state just yet.
stateless: true,
});
if (!authenticationResult.succeeded()) {
return Boom.forbidden(authenticationResult.error);
}
}
try {
const body = { password: newPassword };
await callWithRequest(request, 'shield.changePassword', { username, body });
// Now we authenticate user with the new password again updating current session if any.
if (isCurrentUser) {
const authenticationResult = await login(KibanaRequest.from(request), {
provider: providerToLoginWith,
value: { username, password: newPassword }
});
if (!authenticationResult.succeeded()) {
return Boom.unauthorized((authenticationResult.error));
}
}
} catch(err) {
return wrapError(err);
}
return h.response().code(204);
},
config: {
validate: {
payload: Joi.object({
password: Joi.string(),
newPassword: Joi.string().required()
})
},
pre: [routePreCheckLicenseFn]
}
});
}

View file

@ -39,7 +39,7 @@ const ELASTICSEARCH_PASSWORD = 'ELASTICSEARCH_PASSWORD';
/**
* The Kibana server endpoint used for authentication
*/
const LOGIN_API_ENDPOINT = '/api/security/v1/login';
const LOGIN_API_ENDPOINT = '/internal/security/login';
/**
* Authenticates with Kibana using, if specified, credentials specified by
@ -68,7 +68,7 @@ const credentialsProvidedByEnvironment = (): boolean =>
* Authenticates with Kibana by reading credentials from the
* `CYPRESS_ELASTICSEARCH_USERNAME` and `CYPRESS_ELASTICSEARCH_PASSWORD`
* environment variables, and POSTing the username and password directly to
* Kibana's `security/v1/login` endpoint, bypassing the login page (for speed).
* Kibana's `/internal/security/login` endpoint, bypassing the login page (for speed).
*/
const loginViaEnvironmentCredentials = () => {
cy.log(
@ -90,7 +90,7 @@ const loginViaEnvironmentCredentials = () => {
/**
* Authenticates with Kibana by reading credentials from the
* `kibana.dev.yml` file and POSTing the username and password directly to
* Kibana's `security/v1/login` endpoint, bypassing the login page (for speed).
* Kibana's `/internal/security/login` endpoint, bypassing the login page (for speed).
*/
const loginViaConfig = () => {
cy.log(

View file

@ -1,579 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
(function (root, factory) {
if (typeof define === 'function' && define.amd) { // eslint-disable-line no-undef
define([], factory); // eslint-disable-line no-undef
} else if (typeof exports === 'object') {
module.exports = factory();
} else {
root.ElasticsearchShield = factory();
}
}(this, function () {
return function addShieldApi(Client, config, components) {
const ca = components.clientAction.factory;
Client.prototype.shield = components.clientAction.namespaceFactory();
const shield = Client.prototype.shield.prototype;
/**
* Perform a [shield.authenticate](Retrieve details about the currently authenticated user) request
*
* @param {Object} params - An object with parameters used to carry out this action
*/
shield.authenticate = ca({
params: {},
url: {
fmt: '/_security/_authenticate'
}
});
/**
* Perform a [shield.changePassword](Change the password of a user) request
*
* @param {Object} params - An object with parameters used to carry out this action
* @param {Boolean} params.refresh - Refresh the index after performing the operation
* @param {String} params.username - The username of the user to change the password for
*/
shield.changePassword = ca({
params: {
refresh: {
type: 'boolean'
}
},
urls: [
{
fmt: '/_security/user/<%=username%>/_password',
req: {
username: {
type: 'string',
required: false
}
}
},
{
fmt: '/_security/user/_password'
}
],
needBody: true,
method: 'POST'
});
/**
* Perform a [shield.clearCachedRealms](Clears the internal user caches for specified realms) request
*
* @param {Object} params - An object with parameters used to carry out this action
* @param {String} params.usernames - Comma-separated list of usernames to clear from the cache
* @param {String} params.realms - Comma-separated list of realms to clear
*/
shield.clearCachedRealms = ca({
params: {
usernames: {
type: 'string',
required: false
}
},
url: {
fmt: '/_security/realm/<%=realms%>/_clear_cache',
req: {
realms: {
type: 'string',
required: true
}
}
},
method: 'POST'
});
/**
* Perform a [shield.clearCachedRoles](Clears the internal caches for specified roles) request
*
* @param {Object} params - An object with parameters used to carry out this action
* @param {String} params.name - Role name
*/
shield.clearCachedRoles = ca({
params: {},
url: {
fmt: '/_security/role/<%=name%>/_clear_cache',
req: {
name: {
type: 'string',
required: true
}
}
},
method: 'POST'
});
/**
* Perform a [shield.deleteRole](Remove a role from the native shield realm) request
*
* @param {Object} params - An object with parameters used to carry out this action
* @param {Boolean} params.refresh - Refresh the index after performing the operation
* @param {String} params.name - Role name
*/
shield.deleteRole = ca({
params: {
refresh: {
type: 'boolean'
}
},
url: {
fmt: '/_security/role/<%=name%>',
req: {
name: {
type: 'string',
required: true
}
}
},
method: 'DELETE'
});
/**
* Perform a [shield.deleteUser](Remove a user from the native shield realm) request
*
* @param {Object} params - An object with parameters used to carry out this action
* @param {Boolean} params.refresh - Refresh the index after performing the operation
* @param {String} params.username - username
*/
shield.deleteUser = ca({
params: {
refresh: {
type: 'boolean'
}
},
url: {
fmt: '/_security/user/<%=username%>',
req: {
username: {
type: 'string',
required: true
}
}
},
method: 'DELETE'
});
/**
* Perform a [shield.getRole](Retrieve one or more roles from the native shield realm) request
*
* @param {Object} params - An object with parameters used to carry out this action
* @param {String} params.name - Role name
*/
shield.getRole = ca({
params: {},
urls: [
{
fmt: '/_security/role/<%=name%>',
req: {
name: {
type: 'string',
required: false
}
}
},
{
fmt: '/_security/role'
}
]
});
/**
* Perform a [shield.getUser](Retrieve one or more users from the native shield realm) request
*
* @param {Object} params - An object with parameters used to carry out this action
* @param {String, String[], Boolean} params.username - A comma-separated list of usernames
*/
shield.getUser = ca({
params: {},
urls: [
{
fmt: '/_security/user/<%=username%>',
req: {
username: {
type: 'list',
required: false
}
}
},
{
fmt: '/_security/user'
}
]
});
/**
* Perform a [shield.putRole](Update or create a role for the native shield realm) request
*
* @param {Object} params - An object with parameters used to carry out this action
* @param {Boolean} params.refresh - Refresh the index after performing the operation
* @param {String} params.name - Role name
*/
shield.putRole = ca({
params: {
refresh: {
type: 'boolean'
}
},
url: {
fmt: '/_security/role/<%=name%>',
req: {
name: {
type: 'string',
required: true
}
}
},
needBody: true,
method: 'PUT'
});
/**
* Perform a [shield.putUser](Update or create a user for the native shield realm) request
*
* @param {Object} params - An object with parameters used to carry out this action
* @param {Boolean} params.refresh - Refresh the index after performing the operation
* @param {String} params.username - The username of the User
*/
shield.putUser = ca({
params: {
refresh: {
type: 'boolean'
}
},
url: {
fmt: '/_security/user/<%=username%>',
req: {
username: {
type: 'string',
required: true
}
}
},
needBody: true,
method: 'PUT'
});
/**
* Perform a [shield.getUserPrivileges](Retrieve a user's list of privileges) request
*
*/
shield.getUserPrivileges = ca({
params: {},
urls: [
{
fmt: '/_security/user/_privileges'
}
]
});
/**
* Asks Elasticsearch to prepare SAML authentication request to be sent to
* the 3rd-party SAML identity provider.
*
* @param {string} [acs] Optional assertion consumer service URL to use for SAML request or URL
* in the Kibana to which identity provider will post SAML response. Based on the ACS Elasticsearch
* will choose the right SAML realm.
*
* @param {string} [realm] Optional name of the Elasticsearch SAML realm to use to handle request.
*
* @returns {{realm: string, id: string, redirect: string}} Object that includes identifier
* of the SAML realm used to prepare authentication request, encrypted request token to be
* sent to Elasticsearch with SAML response and redirect URL to the identity provider that
* will be used to authenticate user.
*/
shield.samlPrepare = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/saml/prepare'
}
});
/**
* Sends SAML response returned by identity provider to Elasticsearch for validation.
*
* @param {Array.<string>} ids A list of encrypted request tokens returned within SAML
* preparation response.
* @param {string} content SAML response returned by identity provider.
* @param {string} [realm] Optional string used to identify the name of the OpenID Connect realm
* that should be used to authenticate request.
*
* @returns {{username: string, access_token: string, expires_in: number}} Object that
* includes name of the user, access token to use for any consequent requests that
* need to be authenticated and a number of seconds after which access token will expire.
*/
shield.samlAuthenticate = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/saml/authenticate'
}
});
/**
* Invalidates SAML access token.
*
* @param {string} token SAML access token that needs to be invalidated.
*
* @returns {{redirect?: string}}
*/
shield.samlLogout = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/saml/logout'
}
});
/**
* Invalidates SAML session based on Logout Request received from the Identity Provider.
*
* @param {string} queryString URL encoded query string provided by Identity Provider.
* @param {string} [acs] Optional assertion consumer service URL to use for SAML request or URL in the
* Kibana to which identity provider will post SAML response. Based on the ACS Elasticsearch
* will choose the right SAML realm to invalidate session.
* @param {string} [realm] Optional name of the Elasticsearch SAML realm to use to handle request.
*
* @returns {{redirect?: string}}
*/
shield.samlInvalidate = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/saml/invalidate'
}
});
/**
* Asks Elasticsearch to prepare an OpenID Connect authentication request to be sent to
* the 3rd-party OpenID Connect provider.
*
* @param {string} realm The OpenID Connect realm name in Elasticsearch
*
* @returns {{state: string, nonce: string, redirect: string}} Object that includes two opaque parameters that need
* to be sent to Elasticsearch with the OpenID Connect response and redirect URL to the OpenID Connect provider that
* will be used to authenticate user.
*/
shield.oidcPrepare = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/oidc/prepare'
}
});
/**
* Sends the URL to which the OpenID Connect Provider redirected the UA to Elasticsearch for validation.
*
* @param {string} state The state parameter that was returned by Elasticsearch in the
* preparation response.
* @param {string} nonce The nonce parameter that was returned by Elasticsearch in the
* preparation response.
* @param {string} redirect_uri The URL to where the UA was redirected by the OpenID Connect provider.
* @param {string} [realm] Optional string used to identify the name of the OpenID Connect realm
* that should be used to authenticate request.
*
* @returns {{username: string, access_token: string, refresh_token; string, expires_in: number}} Object that
* includes name of the user, access token to use for any consequent requests that
* need to be authenticated and a number of seconds after which access token will expire.
*/
shield.oidcAuthenticate = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/oidc/authenticate'
}
});
/**
* Invalidates an access token and refresh token pair that was generated after an OpenID Connect authentication.
*
* @param {string} token An access token that was created by authenticating to an OpenID Connect realm and
* that needs to be invalidated.
* @param {string} refresh_token A refresh token that was created by authenticating to an OpenID Connect realm and
* that needs to be invalidated.
*
* @returns {{redirect?: string}} If the Elasticsearch OpenID Connect realm configuration and the
* OpenID Connect provider supports RP-initiated SLO, a URL to redirect the UA
*/
shield.oidcLogout = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/oidc/logout'
}
});
/**
* Refreshes an access token.
*
* @param {string} grant_type Currently only "refresh_token" grant type is supported.
* @param {string} refresh_token One-time refresh token that will be exchanged to the new access/refresh token pair.
*
* @returns {{access_token: string, type: string, expires_in: number, refresh_token: string}}
*/
shield.getAccessToken = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/oauth2/token'
}
});
/**
* Invalidates an access token.
*
* @param {string} token The access token to invalidate
*
* @returns {{created: boolean}}
*/
shield.deleteAccessToken = ca({
method: 'DELETE',
needBody: true,
params: {
token: {
type: 'string'
}
},
url: {
fmt: '/_security/oauth2/token'
}
});
shield.getPrivilege = ca({
method: 'GET',
urls: [{
fmt: '/_security/privilege/<%=privilege%>',
req: {
privilege: {
type: 'string',
required: false
}
}
}, {
fmt: '/_security/privilege'
}]
});
shield.deletePrivilege = ca({
method: 'DELETE',
urls: [{
fmt: '/_security/privilege/<%=application%>/<%=privilege%>',
req: {
application: {
type: 'string',
required: true
},
privilege: {
type: 'string',
required: true
}
}
}]
});
shield.postPrivileges = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/privilege'
}
});
shield.hasPrivileges = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/user/_has_privileges'
}
});
shield.getBuiltinPrivileges = ca({
params: {},
urls: [
{
fmt: '/_security/privilege/_builtin'
}
]
});
/**
* Gets API keys in Elasticsearch
* @param {boolean} owner A boolean flag that can be used to query API keys owned by the currently authenticated user.
* Defaults to false. The realm_name or username parameters cannot be specified when this parameter is set to true as
* they are assumed to be the currently authenticated ones.
*/
shield.getAPIKeys = ca({
method: 'GET',
urls: [{
fmt: `/_security/api_key?owner=<%=owner%>`,
req: {
owner: {
type: 'boolean',
required: true
}
}
}]
});
/**
* Creates an API key in Elasticsearch for the current user.
*
* @param {string} name A name for this API key
* @param {object} role_descriptors Role descriptors for this API key, if not
* provided then permissions of authenticated user are applied.
* @param {string} [expiration] Optional expiration for the API key being generated. If expiration
* is not provided then the API keys do not expire.
*
* @returns {{id: string, name: string, api_key: string, expiration?: number}}
*/
shield.createAPIKey = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/api_key',
},
});
/**
* Invalidates an API key in Elasticsearch.
*
* @param {string} [id] An API key id.
* @param {string} [name] An API key name.
* @param {string} [realm_name] The name of an authentication realm.
* @param {string} [username] The username of a user.
*
* NOTE: While all parameters are optional, at least one of them is required.
*
* @returns {{invalidated_api_keys: string[], previously_invalidated_api_keys: string[], error_count: number, error_details?: object[]}}
*/
shield.invalidateAPIKey = ca({
method: 'DELETE',
needBody: true,
url: {
fmt: '/_security/api_key',
},
});
/**
* Gets an access token in exchange to the certificate chain for the target subject distinguished name.
*
* @param {string[]} x509_certificate_chain An ordered array of base64-encoded (Section 4 of RFC4648 - not
* base64url-encoded) DER PKIX certificate values.
*
* @returns {{access_token: string, type: string, expires_in: number}}
*/
shield.delegatePKI = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/delegate_pki',
},
});
};
}));

View file

@ -1,14 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { once } from 'lodash';
import { Legacy } from 'kibana';
// @ts-ignore
import esShield from './esjs_shield_plugin';
export const getClient = once((server: Legacy.Server) => {
return server.plugins.elasticsearch.createCluster('security', { plugins: [esShield] });
});

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { ApiKey, ApiKeyToInvalidate } from './api_key';
export { User, EditUser, getUserDisplayName } from './user';
export { AuthenticatedUser, canUserChangePassword } from './authenticated_user';
export { BuiltinESPrivileges } from './builtin_es_privileges';

View file

@ -42,7 +42,7 @@ describe('OIDCAuthenticationProvider', () => {
describe('`login` method', () => {
it('redirects third party initiated login attempts to the OpenId Connect Provider.', async () => {
const request = httpServerMock.createKibanaRequest({ path: '/api/security/v1/oidc' });
const request = httpServerMock.createKibanaRequest({ path: '/api/security/oidc' });
mockOptions.client.callAsInternalUser.withArgs('shield.oidcPrepare').resolves({
state: 'statevalue',
@ -205,13 +205,13 @@ describe('OIDCAuthenticationProvider', () => {
describe('authorization code flow', () => {
defineAuthenticationFlowTests(() => ({
request: httpServerMock.createKibanaRequest({
path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere',
path: '/api/security/oidc?code=somecodehere&state=somestatehere',
}),
attempt: {
flow: OIDCAuthenticationFlow.AuthorizationCode,
authenticationResponseURI: '/api/security/v1/oidc?code=somecodehere&state=somestatehere',
authenticationResponseURI: '/api/security/oidc?code=somecodehere&state=somestatehere',
},
expectedRedirectURI: '/api/security/v1/oidc?code=somecodehere&state=somestatehere',
expectedRedirectURI: '/api/security/oidc?code=somecodehere&state=somestatehere',
}));
});
@ -219,14 +219,13 @@ describe('OIDCAuthenticationProvider', () => {
defineAuthenticationFlowTests(() => ({
request: httpServerMock.createKibanaRequest({
path:
'/api/security/v1/oidc?authenticationResponseURI=http://kibana/api/security/v1/oidc/implicit#id_token=sometoken',
'/api/security/oidc?authenticationResponseURI=http://kibana/api/security/oidc/implicit#id_token=sometoken',
}),
attempt: {
flow: OIDCAuthenticationFlow.Implicit,
authenticationResponseURI:
'http://kibana/api/security/v1/oidc/implicit#id_token=sometoken',
authenticationResponseURI: 'http://kibana/api/security/oidc/implicit#id_token=sometoken',
},
expectedRedirectURI: 'http://kibana/api/security/v1/oidc/implicit#id_token=sometoken',
expectedRedirectURI: 'http://kibana/api/security/oidc/implicit#id_token=sometoken',
}));
});
});

View file

@ -0,0 +1,576 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export function elasticsearchClientPlugin(Client: any, config: unknown, components: any) {
const ca = components.clientAction.factory;
Client.prototype.shield = components.clientAction.namespaceFactory();
const shield = Client.prototype.shield.prototype;
/**
* Perform a [shield.authenticate](Retrieve details about the currently authenticated user) request
*
* @param {Object} params - An object with parameters used to carry out this action
*/
shield.authenticate = ca({
params: {},
url: {
fmt: '/_security/_authenticate',
},
});
/**
* Perform a [shield.changePassword](Change the password of a user) request
*
* @param {Object} params - An object with parameters used to carry out this action
* @param {Boolean} params.refresh - Refresh the index after performing the operation
* @param {String} params.username - The username of the user to change the password for
*/
shield.changePassword = ca({
params: {
refresh: {
type: 'boolean',
},
},
urls: [
{
fmt: '/_security/user/<%=username%>/_password',
req: {
username: {
type: 'string',
required: false,
},
},
},
{
fmt: '/_security/user/_password',
},
],
needBody: true,
method: 'POST',
});
/**
* Perform a [shield.clearCachedRealms](Clears the internal user caches for specified realms) request
*
* @param {Object} params - An object with parameters used to carry out this action
* @param {String} params.usernames - Comma-separated list of usernames to clear from the cache
* @param {String} params.realms - Comma-separated list of realms to clear
*/
shield.clearCachedRealms = ca({
params: {
usernames: {
type: 'string',
required: false,
},
},
url: {
fmt: '/_security/realm/<%=realms%>/_clear_cache',
req: {
realms: {
type: 'string',
required: true,
},
},
},
method: 'POST',
});
/**
* Perform a [shield.clearCachedRoles](Clears the internal caches for specified roles) request
*
* @param {Object} params - An object with parameters used to carry out this action
* @param {String} params.name - Role name
*/
shield.clearCachedRoles = ca({
params: {},
url: {
fmt: '/_security/role/<%=name%>/_clear_cache',
req: {
name: {
type: 'string',
required: true,
},
},
},
method: 'POST',
});
/**
* Perform a [shield.deleteRole](Remove a role from the native shield realm) request
*
* @param {Object} params - An object with parameters used to carry out this action
* @param {Boolean} params.refresh - Refresh the index after performing the operation
* @param {String} params.name - Role name
*/
shield.deleteRole = ca({
params: {
refresh: {
type: 'boolean',
},
},
url: {
fmt: '/_security/role/<%=name%>',
req: {
name: {
type: 'string',
required: true,
},
},
},
method: 'DELETE',
});
/**
* Perform a [shield.deleteUser](Remove a user from the native shield realm) request
*
* @param {Object} params - An object with parameters used to carry out this action
* @param {Boolean} params.refresh - Refresh the index after performing the operation
* @param {String} params.username - username
*/
shield.deleteUser = ca({
params: {
refresh: {
type: 'boolean',
},
},
url: {
fmt: '/_security/user/<%=username%>',
req: {
username: {
type: 'string',
required: true,
},
},
},
method: 'DELETE',
});
/**
* Perform a [shield.getRole](Retrieve one or more roles from the native shield realm) request
*
* @param {Object} params - An object with parameters used to carry out this action
* @param {String} params.name - Role name
*/
shield.getRole = ca({
params: {},
urls: [
{
fmt: '/_security/role/<%=name%>',
req: {
name: {
type: 'string',
required: false,
},
},
},
{
fmt: '/_security/role',
},
],
});
/**
* Perform a [shield.getUser](Retrieve one or more users from the native shield realm) request
*
* @param {Object} params - An object with parameters used to carry out this action
* @param {String, String[], Boolean} params.username - A comma-separated list of usernames
*/
shield.getUser = ca({
params: {},
urls: [
{
fmt: '/_security/user/<%=username%>',
req: {
username: {
type: 'list',
required: false,
},
},
},
{
fmt: '/_security/user',
},
],
});
/**
* Perform a [shield.putRole](Update or create a role for the native shield realm) request
*
* @param {Object} params - An object with parameters used to carry out this action
* @param {Boolean} params.refresh - Refresh the index after performing the operation
* @param {String} params.name - Role name
*/
shield.putRole = ca({
params: {
refresh: {
type: 'boolean',
},
},
url: {
fmt: '/_security/role/<%=name%>',
req: {
name: {
type: 'string',
required: true,
},
},
},
needBody: true,
method: 'PUT',
});
/**
* Perform a [shield.putUser](Update or create a user for the native shield realm) request
*
* @param {Object} params - An object with parameters used to carry out this action
* @param {Boolean} params.refresh - Refresh the index after performing the operation
* @param {String} params.username - The username of the User
*/
shield.putUser = ca({
params: {
refresh: {
type: 'boolean',
},
},
url: {
fmt: '/_security/user/<%=username%>',
req: {
username: {
type: 'string',
required: true,
},
},
},
needBody: true,
method: 'PUT',
});
/**
* Perform a [shield.getUserPrivileges](Retrieve a user's list of privileges) request
*
*/
shield.getUserPrivileges = ca({
params: {},
urls: [
{
fmt: '/_security/user/_privileges',
},
],
});
/**
* Asks Elasticsearch to prepare SAML authentication request to be sent to
* the 3rd-party SAML identity provider.
*
* @param {string} [acs] Optional assertion consumer service URL to use for SAML request or URL
* in the Kibana to which identity provider will post SAML response. Based on the ACS Elasticsearch
* will choose the right SAML realm.
*
* @param {string} [realm] Optional name of the Elasticsearch SAML realm to use to handle request.
*
* @returns {{realm: string, id: string, redirect: string}} Object that includes identifier
* of the SAML realm used to prepare authentication request, encrypted request token to be
* sent to Elasticsearch with SAML response and redirect URL to the identity provider that
* will be used to authenticate user.
*/
shield.samlPrepare = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/saml/prepare',
},
});
/**
* Sends SAML response returned by identity provider to Elasticsearch for validation.
*
* @param {Array.<string>} ids A list of encrypted request tokens returned within SAML
* preparation response.
* @param {string} content SAML response returned by identity provider.
* @param {string} [realm] Optional string used to identify the name of the OpenID Connect realm
* that should be used to authenticate request.
*
* @returns {{username: string, access_token: string, expires_in: number}} Object that
* includes name of the user, access token to use for any consequent requests that
* need to be authenticated and a number of seconds after which access token will expire.
*/
shield.samlAuthenticate = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/saml/authenticate',
},
});
/**
* Invalidates SAML access token.
*
* @param {string} token SAML access token that needs to be invalidated.
*
* @returns {{redirect?: string}}
*/
shield.samlLogout = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/saml/logout',
},
});
/**
* Invalidates SAML session based on Logout Request received from the Identity Provider.
*
* @param {string} queryString URL encoded query string provided by Identity Provider.
* @param {string} [acs] Optional assertion consumer service URL to use for SAML request or URL in the
* Kibana to which identity provider will post SAML response. Based on the ACS Elasticsearch
* will choose the right SAML realm to invalidate session.
* @param {string} [realm] Optional name of the Elasticsearch SAML realm to use to handle request.
*
* @returns {{redirect?: string}}
*/
shield.samlInvalidate = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/saml/invalidate',
},
});
/**
* Asks Elasticsearch to prepare an OpenID Connect authentication request to be sent to
* the 3rd-party OpenID Connect provider.
*
* @param {string} realm The OpenID Connect realm name in Elasticsearch
*
* @returns {{state: string, nonce: string, redirect: string}} Object that includes two opaque parameters that need
* to be sent to Elasticsearch with the OpenID Connect response and redirect URL to the OpenID Connect provider that
* will be used to authenticate user.
*/
shield.oidcPrepare = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/oidc/prepare',
},
});
/**
* Sends the URL to which the OpenID Connect Provider redirected the UA to Elasticsearch for validation.
*
* @param {string} state The state parameter that was returned by Elasticsearch in the
* preparation response.
* @param {string} nonce The nonce parameter that was returned by Elasticsearch in the
* preparation response.
* @param {string} redirect_uri The URL to where the UA was redirected by the OpenID Connect provider.
* @param {string} [realm] Optional string used to identify the name of the OpenID Connect realm
* that should be used to authenticate request.
*
* @returns {{username: string, access_token: string, refresh_token; string, expires_in: number}} Object that
* includes name of the user, access token to use for any consequent requests that
* need to be authenticated and a number of seconds after which access token will expire.
*/
shield.oidcAuthenticate = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/oidc/authenticate',
},
});
/**
* Invalidates an access token and refresh token pair that was generated after an OpenID Connect authentication.
*
* @param {string} token An access token that was created by authenticating to an OpenID Connect realm and
* that needs to be invalidated.
* @param {string} refresh_token A refresh token that was created by authenticating to an OpenID Connect realm and
* that needs to be invalidated.
*
* @returns {{redirect?: string}} If the Elasticsearch OpenID Connect realm configuration and the
* OpenID Connect provider supports RP-initiated SLO, a URL to redirect the UA
*/
shield.oidcLogout = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/oidc/logout',
},
});
/**
* Refreshes an access token.
*
* @param {string} grant_type Currently only "refresh_token" grant type is supported.
* @param {string} refresh_token One-time refresh token that will be exchanged to the new access/refresh token pair.
*
* @returns {{access_token: string, type: string, expires_in: number, refresh_token: string}}
*/
shield.getAccessToken = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/oauth2/token',
},
});
/**
* Invalidates an access token.
*
* @param {string} token The access token to invalidate
*
* @returns {{created: boolean}}
*/
shield.deleteAccessToken = ca({
method: 'DELETE',
needBody: true,
params: {
token: {
type: 'string',
},
},
url: {
fmt: '/_security/oauth2/token',
},
});
shield.getPrivilege = ca({
method: 'GET',
urls: [
{
fmt: '/_security/privilege/<%=privilege%>',
req: {
privilege: {
type: 'string',
required: false,
},
},
},
{
fmt: '/_security/privilege',
},
],
});
shield.deletePrivilege = ca({
method: 'DELETE',
urls: [
{
fmt: '/_security/privilege/<%=application%>/<%=privilege%>',
req: {
application: {
type: 'string',
required: true,
},
privilege: {
type: 'string',
required: true,
},
},
},
],
});
shield.postPrivileges = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/privilege',
},
});
shield.hasPrivileges = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/user/_has_privileges',
},
});
shield.getBuiltinPrivileges = ca({
params: {},
urls: [
{
fmt: '/_security/privilege/_builtin',
},
],
});
/**
* Gets API keys in Elasticsearch
* @param {boolean} owner A boolean flag that can be used to query API keys owned by the currently authenticated user.
* Defaults to false. The realm_name or username parameters cannot be specified when this parameter is set to true as
* they are assumed to be the currently authenticated ones.
*/
shield.getAPIKeys = ca({
method: 'GET',
urls: [
{
fmt: `/_security/api_key?owner=<%=owner%>`,
req: {
owner: {
type: 'boolean',
required: true,
},
},
},
],
});
/**
* Creates an API key in Elasticsearch for the current user.
*
* @param {string} name A name for this API key
* @param {object} role_descriptors Role descriptors for this API key, if not
* provided then permissions of authenticated user are applied.
* @param {string} [expiration] Optional expiration for the API key being generated. If expiration
* is not provided then the API keys do not expire.
*
* @returns {{id: string, name: string, api_key: string, expiration?: number}}
*/
shield.createAPIKey = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/api_key',
},
});
/**
* Invalidates an API key in Elasticsearch.
*
* @param {string} [id] An API key id.
* @param {string} [name] An API key name.
* @param {string} [realm_name] The name of an authentication realm.
* @param {string} [username] The username of a user.
*
* NOTE: While all parameters are optional, at least one of them is required.
*
* @returns {{invalidated_api_keys: string[], previously_invalidated_api_keys: string[], error_count: number, error_details?: object[]}}
*/
shield.invalidateAPIKey = ca({
method: 'DELETE',
needBody: true,
url: {
fmt: '/_security/api_key',
},
});
/**
* Gets an access token in exchange to the certificate chain for the target subject distinguished name.
*
* @param {string[]} x509_certificate_chain An ordered array of base64-encoded (Section 4 of RFC4648 - not
* base64url-encoded) DER PKIX certificate values.
*
* @returns {{access_token: string, type: string, expires_in: number}}
*/
shield.delegatePKI = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/delegate_pki',
},
});
}

View file

@ -5,11 +5,25 @@
*/
import Boom from 'boom';
import { CustomHttpResponseOptions, ResponseError } from '../../../../src/core/server';
export function wrapError(error: any) {
return Boom.boomify(error, { statusCode: getErrorStatusCode(error) });
}
/**
* Wraps error into error suitable for Core's custom error response.
* @param error Any error instance.
*/
export function wrapIntoCustomErrorResponse(error: any) {
const wrappedError = wrapError(error);
return {
body: wrappedError,
headers: wrappedError.output.headers,
statusCode: wrappedError.output.statusCode,
} as CustomHttpResponseOptions<ResponseError>;
}
/**
* Extracts error code from Boom and Elasticsearch "native" errors.
* @param error Error instance to extract status code from.

View file

@ -9,18 +9,8 @@ import { ConfigSchema } from './config';
import { Plugin } from './plugin';
// These exports are part of public Security plugin contract, any change in signature of exported
// functions or removal of exports should be considered as a breaking change. Ideally we should
// reduce number of such exports to zero and provide everything we want to expose via Setup/Start
// run-time contracts.
export { wrapError } from './errors';
export {
canRedirectRequest,
AuthenticationResult,
DeauthenticationResult,
OIDCAuthenticationFlow,
CreateAPIKeyResult,
} from './authentication';
// functions or removal of exports should be considered as a breaking change.
export { AuthenticationResult, DeauthenticationResult, CreateAPIKeyResult } from './authentication';
export { PluginSetupContract } from './plugin';
export const config = { schema: ConfigSchema };

View file

@ -7,6 +7,7 @@
import { of } from 'rxjs';
import { ByteSizeValue } from '@kbn/config-schema';
import { IClusterClient, CoreSetup } from '../../../../src/core/server';
import { elasticsearchClientPlugin } from './elasticsearch_client_plugin';
import { Plugin, PluginSetupDependencies } from './plugin';
import { coreMock, elasticsearchServiceMock } from '../../../../src/core/server/mocks';
@ -48,12 +49,6 @@ describe('Security Plugin', () => {
Object {
"__legacyCompat": Object {
"config": Object {
"authc": Object {
"providers": Array [
"saml",
"token",
],
},
"cookieName": "sid",
"loginAssistanceMessage": undefined,
"secureCookies": true,
@ -115,7 +110,7 @@ describe('Security Plugin', () => {
expect(mockCoreSetup.elasticsearch.createClient).toHaveBeenCalledTimes(1);
expect(mockCoreSetup.elasticsearch.createClient).toHaveBeenCalledWith('security', {
plugins: [require('../../../legacy/server/lib/esjs_shield_plugin')],
plugins: [elasticsearchClientPlugin],
});
});
});

View file

@ -28,6 +28,7 @@ import { defineRoutes } from './routes';
import { SecurityLicenseService, SecurityLicense } from './licensing';
import { setupSavedObjects } from './saved_objects';
import { SecurityAuditLogger } from './audit';
import { elasticsearchClientPlugin } from './elasticsearch_client_plugin';
export type SpacesService = Pick<
SpacesPluginSetup['spacesService'],
@ -76,7 +77,8 @@ export interface PluginSetupContract {
lifespan: number | null;
};
secureCookies: boolean;
authc: { providers: string[] };
cookieName: string;
loginAssistanceMessage: string;
}>;
};
}
@ -128,7 +130,7 @@ export class Plugin {
.toPromise();
this.clusterClient = core.elasticsearch.createClient('security', {
plugins: [require('../../../legacy/server/lib/esjs_shield_plugin')],
plugins: [elasticsearchClientPlugin],
});
const { license, update: updateLicense } = new SecurityLicenseService().setup();
@ -213,7 +215,6 @@ export class Plugin {
},
secureCookies: config.secureCookies,
cookieName: config.cookieName,
authc: { providers: config.authc.providers },
},
},
});

View file

@ -0,0 +1,160 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../src/core/server';
import { LICENSE_CHECK_STATE, LicenseCheck } from '../../../../licensing/server';
import { defineGetApiKeysRoutes } from './get';
import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks';
import { routeDefinitionParamsMock } from '../index.mock';
import Boom from 'boom';
interface TestOptions {
isAdmin?: boolean;
licenseCheckResult?: LicenseCheck;
apiResponse?: () => Promise<unknown>;
asserts: { statusCode: number; result?: Record<string, any> };
}
describe('Get API keys', () => {
const getApiKeysTest = (
description: string,
{
licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid },
apiResponse,
asserts,
isAdmin = true,
}: TestOptions
) => {
test(description, async () => {
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient);
if (apiResponse) {
mockScopedClusterClient.callAsCurrentUser.mockImplementation(apiResponse);
}
defineGetApiKeysRoutes(mockRouteDefinitionParams);
const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls;
const headers = { authorization: 'foo' };
const mockRequest = httpServerMock.createKibanaRequest({
method: 'get',
path: '/internal/security/api_key',
query: { isAdmin: isAdmin.toString() },
headers,
});
const mockContext = ({
licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } },
} as unknown) as RequestHandlerContext;
const response = await handler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(asserts.statusCode);
expect(response.payload).toEqual(asserts.result);
if (apiResponse) {
expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest);
expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith(
'shield.getAPIKeys',
{ owner: !isAdmin }
);
} else {
expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled();
}
expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic');
});
};
describe('failure', () => {
getApiKeysTest('returns result of license checker', {
licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' },
asserts: { statusCode: 403, result: { message: 'test forbidden message' } },
});
const error = Boom.notAcceptable('test not acceptable message');
getApiKeysTest('returns error from cluster client', {
apiResponse: async () => {
throw error;
},
asserts: { statusCode: 406, result: error },
});
});
describe('success', () => {
getApiKeysTest('returns API keys', {
apiResponse: async () => ({
api_keys: [
{
id: 'YCLV7m0BJ3xI4hhWB648',
name: 'test-api-key',
creation: 1571670001452,
expiration: 1571756401452,
invalidated: false,
username: 'elastic',
realm: 'reserved',
},
],
}),
asserts: {
statusCode: 200,
result: {
apiKeys: [
{
id: 'YCLV7m0BJ3xI4hhWB648',
name: 'test-api-key',
creation: 1571670001452,
expiration: 1571756401452,
invalidated: false,
username: 'elastic',
realm: 'reserved',
},
],
},
},
});
getApiKeysTest('returns only valid API keys', {
apiResponse: async () => ({
api_keys: [
{
id: 'YCLV7m0BJ3xI4hhWB648',
name: 'test-api-key1',
creation: 1571670001452,
expiration: 1571756401452,
invalidated: true,
username: 'elastic',
realm: 'reserved',
},
{
id: 'YCLV7m0BJ3xI4hhWB648',
name: 'test-api-key2',
creation: 1571670001452,
expiration: 1571756401452,
invalidated: false,
username: 'elastic',
realm: 'reserved',
},
],
}),
asserts: {
statusCode: 200,
result: {
apiKeys: [
{
id: 'YCLV7m0BJ3xI4hhWB648',
name: 'test-api-key2',
creation: 1571670001452,
expiration: 1571756401452,
invalidated: false,
username: 'elastic',
realm: 'reserved',
},
],
},
},
});
});
});

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema } from '@kbn/config-schema';
import { ApiKey } from '../../../common/model';
import { wrapIntoCustomErrorResponse } from '../../errors';
import { createLicensedRouteHandler } from '../licensed_route_handler';
import { RouteDefinitionParams } from '..';
export function defineGetApiKeysRoutes({ router, clusterClient }: RouteDefinitionParams) {
router.get(
{
path: '/internal/security/api_key',
validate: {
query: schema.object({
// We don't use `schema.boolean` here, because all query string parameters are treated as
// strings and @kbn/config-schema doesn't coerce strings to booleans.
//
// A boolean flag that can be used to query API keys owned by the currently authenticated
// user. `false` means that only API keys of currently authenticated user will be returned.
isAdmin: schema.oneOf([schema.literal('true'), schema.literal('false')]),
}),
},
},
createLicensedRouteHandler(async (context, request, response) => {
try {
const isAdmin = request.query.isAdmin === 'true';
const { api_keys: apiKeys } = (await clusterClient
.asScoped(request)
.callAsCurrentUser('shield.getAPIKeys', { owner: !isAdmin })) as { api_keys: ApiKey[] };
const validKeys = apiKeys.filter(({ invalidated }) => !invalidated);
return response.ok({ body: { apiKeys: validKeys } });
} catch (error) {
return response.customError(wrapIntoCustomErrorResponse(error));
}
})
);
}

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { defineGetApiKeysRoutes } from './get';
import { defineCheckPrivilegesRoutes } from './privileges';
import { defineInvalidateApiKeysRoutes } from './invalidate';
import { RouteDefinitionParams } from '..';
export function defineApiKeysRoutes(params: RouteDefinitionParams) {
defineGetApiKeysRoutes(params);
defineCheckPrivilegesRoutes(params);
defineInvalidateApiKeysRoutes(params);
}

View file

@ -0,0 +1,220 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import { Type } from '@kbn/config-schema';
import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../src/core/server';
import { LICENSE_CHECK_STATE, LicenseCheck } from '../../../../licensing/server';
import { defineInvalidateApiKeysRoutes } from './invalidate';
import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks';
import { routeDefinitionParamsMock } from '../index.mock';
interface TestOptions {
licenseCheckResult?: LicenseCheck;
apiResponses?: Array<() => Promise<unknown>>;
payload?: Record<string, any>;
asserts: { statusCode: number; result?: Record<string, any>; apiArguments?: unknown[][] };
}
describe('Invalidate API keys', () => {
const postInvalidateTest = (
description: string,
{
licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid },
apiResponses = [],
asserts,
payload,
}: TestOptions
) => {
test(description, async () => {
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient);
for (const apiResponse of apiResponses) {
mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(apiResponse);
}
defineInvalidateApiKeysRoutes(mockRouteDefinitionParams);
const [[{ validate }, handler]] = mockRouteDefinitionParams.router.post.mock.calls;
const headers = { authorization: 'foo' };
const mockRequest = httpServerMock.createKibanaRequest({
method: 'post',
path: '/internal/security/api_key/invalidate',
body: payload !== undefined ? (validate as any).body.validate(payload) : undefined,
headers,
});
const mockContext = ({
licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } },
} as unknown) as RequestHandlerContext;
const response = await handler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(asserts.statusCode);
expect(response.payload).toEqual(asserts.result);
if (Array.isArray(asserts.apiArguments)) {
for (const apiArguments of asserts.apiArguments) {
expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(
mockRequest
);
expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith(...apiArguments);
}
} else {
expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled();
}
expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic');
});
};
describe('request validation', () => {
let requestBodySchema: Type<any>;
beforeEach(() => {
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
defineInvalidateApiKeysRoutes(mockRouteDefinitionParams);
const [[{ validate }]] = mockRouteDefinitionParams.router.post.mock.calls;
requestBodySchema = (validate as any).body;
});
test('requires both isAdmin and apiKeys parameters', () => {
expect(() =>
requestBodySchema.validate({}, {}, 'request body')
).toThrowErrorMatchingInlineSnapshot(
`"[request body.apiKeys]: expected value of type [array] but got [undefined]"`
);
expect(() =>
requestBodySchema.validate({ apiKeys: [] }, {}, 'request body')
).toThrowErrorMatchingInlineSnapshot(
`"[request body.isAdmin]: expected value of type [boolean] but got [undefined]"`
);
expect(() =>
requestBodySchema.validate({ apiKeys: {}, isAdmin: true }, {}, 'request body')
).toThrowErrorMatchingInlineSnapshot(
`"[request body.apiKeys]: expected value of type [array] but got [Object]"`
);
expect(() =>
requestBodySchema.validate(
{
apiKeys: [{ id: 'some-id', name: 'some-name', unknown: 'some-unknown' }],
isAdmin: true,
},
{},
'request body'
)
).toThrowErrorMatchingInlineSnapshot(
`"[request body.apiKeys.0.unknown]: definition for this key is missing"`
);
});
});
describe('failure', () => {
postInvalidateTest('returns result of license checker', {
licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' },
payload: { apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }], isAdmin: true },
asserts: { statusCode: 403, result: { message: 'test forbidden message' } },
});
const error = Boom.notAcceptable('test not acceptable message');
postInvalidateTest('returns error from cluster client', {
apiResponses: [
async () => {
throw error;
},
],
payload: {
apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }],
isAdmin: true,
},
asserts: {
apiArguments: [['shield.invalidateAPIKey', { body: { id: 'si8If24B1bKsmSLTAhJV' } }]],
statusCode: 200,
result: {
itemsInvalidated: [],
errors: [
{
id: 'si8If24B1bKsmSLTAhJV',
name: 'my-api-key',
error: Boom.notAcceptable('test not acceptable message'),
},
],
},
},
});
});
describe('success', () => {
postInvalidateTest('invalidates API keys', {
apiResponses: [async () => null],
payload: {
apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }],
isAdmin: true,
},
asserts: {
apiArguments: [['shield.invalidateAPIKey', { body: { id: 'si8If24B1bKsmSLTAhJV' } }]],
statusCode: 200,
result: {
itemsInvalidated: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }],
errors: [],
},
},
});
postInvalidateTest('adds "owner" to body if isAdmin=false', {
apiResponses: [async () => null],
payload: {
apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }],
isAdmin: false,
},
asserts: {
apiArguments: [
['shield.invalidateAPIKey', { body: { id: 'si8If24B1bKsmSLTAhJV', owner: true } }],
],
statusCode: 200,
result: {
itemsInvalidated: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }],
errors: [],
},
},
});
postInvalidateTest('returns only successful invalidation requests', {
apiResponses: [
async () => null,
async () => {
throw Boom.notAcceptable('test not acceptable message');
},
],
payload: {
apiKeys: [
{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key1' },
{ id: 'ab8If24B1bKsmSLTAhNC', name: 'my-api-key2' },
],
isAdmin: true,
},
asserts: {
apiArguments: [
['shield.invalidateAPIKey', { body: { id: 'si8If24B1bKsmSLTAhJV' } }],
['shield.invalidateAPIKey', { body: { id: 'ab8If24B1bKsmSLTAhNC' } }],
],
statusCode: 200,
result: {
itemsInvalidated: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key1' }],
errors: [
{
id: 'ab8If24B1bKsmSLTAhNC',
name: 'my-api-key2',
error: Boom.notAcceptable('test not acceptable message'),
},
],
},
},
});
});
});

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema } from '@kbn/config-schema';
import { createLicensedRouteHandler } from '../licensed_route_handler';
import { ApiKey } from '../../../common/model';
import { wrapError, wrapIntoCustomErrorResponse } from '../../errors';
import { RouteDefinitionParams } from '..';
interface ResponseType {
itemsInvalidated: Array<Pick<ApiKey, 'id' | 'name'>>;
errors: Array<Pick<ApiKey, 'id' | 'name'> & { error: Error }>;
}
export function defineInvalidateApiKeysRoutes({ router, clusterClient }: RouteDefinitionParams) {
router.post(
{
path: '/internal/security/api_key/invalidate',
validate: {
body: schema.object({
apiKeys: schema.arrayOf(schema.object({ id: schema.string(), name: schema.string() })),
isAdmin: schema.boolean(),
}),
},
},
createLicensedRouteHandler(async (context, request, response) => {
try {
const scopedClusterClient = clusterClient.asScoped(request);
// Invalidate all API keys in parallel.
const invalidationResult = (
await Promise.all(
request.body.apiKeys.map(async key => {
try {
const body: { id: string; owner?: boolean } = { id: key.id };
if (!request.body.isAdmin) {
body.owner = true;
}
// Send the request to invalidate the API key and return an error if it could not be deleted.
await scopedClusterClient.callAsCurrentUser('shield.invalidateAPIKey', { body });
return { key, error: undefined };
} catch (error) {
return { key, error: wrapError(error) };
}
})
)
).reduce(
(responseBody, { key, error }) => {
if (error) {
responseBody.errors.push({ ...key, error });
} else {
responseBody.itemsInvalidated.push(key);
}
return responseBody;
},
{ itemsInvalidated: [], errors: [] } as ResponseType
);
return response.ok({ body: invalidationResult });
} catch (error) {
return response.customError(wrapIntoCustomErrorResponse(error));
}
})
);
}

View file

@ -0,0 +1,187 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import { LICENSE_CHECK_STATE, LicenseCheck } from '../../../../licensing/server';
import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../src/core/server';
import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks';
import { routeDefinitionParamsMock } from '../index.mock';
import { defineCheckPrivilegesRoutes } from './privileges';
interface TestOptions {
licenseCheckResult?: LicenseCheck;
apiResponses?: Array<() => Promise<unknown>>;
asserts: { statusCode: number; result?: Record<string, any>; apiArguments?: unknown[][] };
}
describe('Check API keys privileges', () => {
const getPrivilegesTest = (
description: string,
{
licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid },
apiResponses = [],
asserts,
}: TestOptions
) => {
test(description, async () => {
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient);
for (const apiResponse of apiResponses) {
mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(apiResponse);
}
defineCheckPrivilegesRoutes(mockRouteDefinitionParams);
const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls;
const headers = { authorization: 'foo' };
const mockRequest = httpServerMock.createKibanaRequest({
method: 'get',
path: '/internal/security/api_key/privileges',
headers,
});
const mockContext = ({
licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } },
} as unknown) as RequestHandlerContext;
const response = await handler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(asserts.statusCode);
expect(response.payload).toEqual(asserts.result);
if (Array.isArray(asserts.apiArguments)) {
for (const apiArguments of asserts.apiArguments) {
expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(
mockRequest
);
expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith(...apiArguments);
}
} else {
expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled();
}
expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic');
});
};
describe('failure', () => {
getPrivilegesTest('returns result of license checker', {
licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' },
asserts: { statusCode: 403, result: { message: 'test forbidden message' } },
});
const error = Boom.notAcceptable('test not acceptable message');
getPrivilegesTest('returns error from cluster client', {
apiResponses: [
async () => {
throw error;
},
async () => {},
],
asserts: {
apiArguments: [
['shield.hasPrivileges', { body: { cluster: ['manage_security', 'manage_api_key'] } }],
['shield.getAPIKeys', { owner: true }],
],
statusCode: 406,
result: error,
},
});
});
describe('success', () => {
getPrivilegesTest('returns areApiKeysEnabled and isAdmin', {
apiResponses: [
async () => ({
username: 'elastic',
has_all_requested: true,
cluster: { manage_api_key: true, manage_security: true },
index: {},
application: {},
}),
async () => ({
api_keys: [
{
id: 'si8If24B1bKsmSLTAhJV',
name: 'my-api-key',
creation: 1574089261632,
expiration: 1574175661632,
invalidated: false,
username: 'elastic',
realm: 'reserved',
},
],
}),
],
asserts: {
apiArguments: [
['shield.getAPIKeys', { owner: true }],
['shield.hasPrivileges', { body: { cluster: ['manage_security', 'manage_api_key'] } }],
],
statusCode: 200,
result: { areApiKeysEnabled: true, isAdmin: true },
},
});
getPrivilegesTest(
'returns areApiKeysEnabled=false when getAPIKeys error message includes "api keys are not enabled"',
{
apiResponses: [
async () => ({
username: 'elastic',
has_all_requested: true,
cluster: { manage_api_key: true, manage_security: true },
index: {},
application: {},
}),
async () => {
throw Boom.unauthorized('api keys are not enabled');
},
],
asserts: {
apiArguments: [
['shield.getAPIKeys', { owner: true }],
['shield.hasPrivileges', { body: { cluster: ['manage_security', 'manage_api_key'] } }],
],
statusCode: 200,
result: { areApiKeysEnabled: false, isAdmin: true },
},
}
);
getPrivilegesTest('returns isAdmin=false when user has insufficient privileges', {
apiResponses: [
async () => ({
username: 'elastic',
has_all_requested: true,
cluster: { manage_api_key: false, manage_security: false },
index: {},
application: {},
}),
async () => ({
api_keys: [
{
id: 'si8If24B1bKsmSLTAhJV',
name: 'my-api-key',
creation: 1574089261632,
expiration: 1574175661632,
invalidated: false,
username: 'elastic',
realm: 'reserved',
},
],
}),
],
asserts: {
apiArguments: [
['shield.getAPIKeys', { owner: true }],
['shield.hasPrivileges', { body: { cluster: ['manage_security', 'manage_api_key'] } }],
],
statusCode: 200,
result: { areApiKeysEnabled: true, isAdmin: false },
},
});
});
});

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { wrapIntoCustomErrorResponse } from '../../errors';
import { createLicensedRouteHandler } from '../licensed_route_handler';
import { RouteDefinitionParams } from '..';
export function defineCheckPrivilegesRoutes({ router, clusterClient }: RouteDefinitionParams) {
router.get(
{
path: '/internal/security/api_key/privileges',
validate: false,
},
createLicensedRouteHandler(async (context, request, response) => {
try {
const scopedClusterClient = clusterClient.asScoped(request);
const [
{
cluster: { manage_security: manageSecurity, manage_api_key: manageApiKey },
},
{ areApiKeysEnabled },
] = await Promise.all([
scopedClusterClient.callAsCurrentUser('shield.hasPrivileges', {
body: { cluster: ['manage_security', 'manage_api_key'] },
}),
scopedClusterClient.callAsCurrentUser('shield.getAPIKeys', { owner: true }).then(
// If the API returns a truthy result that means it's enabled.
result => ({ areApiKeysEnabled: !!result }),
// This is a brittle dependency upon message. Tracked by https://github.com/elastic/elasticsearch/issues/47759.
e =>
e.message.includes('api keys are not enabled')
? Promise.resolve({ areApiKeysEnabled: false })
: Promise.reject(e)
),
]);
return response.ok({
body: { areApiKeysEnabled, isAdmin: manageSecurity || manageApiKey },
});
} catch (error) {
return response.customError(wrapIntoCustomErrorResponse(error));
}
})
);
}

View file

@ -0,0 +1,172 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Type } from '@kbn/config-schema';
import {
IRouter,
kibanaResponseFactory,
RequestHandler,
RequestHandlerContext,
RouteConfig,
} from '../../../../../../src/core/server';
import { LICENSE_CHECK_STATE } from '../../../../licensing/server';
import { Authentication, AuthenticationResult } from '../../authentication';
import { ConfigType } from '../../config';
import { LegacyAPI } from '../../plugin';
import { defineBasicRoutes } from './basic';
import {
elasticsearchServiceMock,
httpServerMock,
httpServiceMock,
loggingServiceMock,
} from '../../../../../../src/core/server/mocks';
import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock';
import { authenticationMock } from '../../authentication/index.mock';
import { authorizationMock } from '../../authorization/index.mock';
describe('Basic authentication routes', () => {
let router: jest.Mocked<IRouter>;
let authc: jest.Mocked<Authentication>;
let mockContext: RequestHandlerContext;
beforeEach(() => {
router = httpServiceMock.createRouter();
authc = authenticationMock.create();
mockContext = ({
licensing: {
license: { check: jest.fn().mockReturnValue({ check: LICENSE_CHECK_STATE.Valid }) },
},
} as unknown) as RequestHandlerContext;
defineBasicRoutes({
router,
clusterClient: elasticsearchServiceMock.createClusterClient(),
basePath: httpServiceMock.createBasePath(),
logger: loggingServiceMock.create().get(),
config: { authc: { providers: ['saml'] } } as ConfigType,
authc,
authz: authorizationMock.create(),
getLegacyAPI: () => ({ cspRules: 'test-csp-rule' } as LegacyAPI),
});
});
describe('login', () => {
let routeHandler: RequestHandler<any, any, any>;
let routeConfig: RouteConfig<any, any, any, any>;
const mockRequest = httpServerMock.createKibanaRequest({
body: { username: 'user', password: 'password' },
});
beforeEach(() => {
const [loginRouteConfig, loginRouteHandler] = router.post.mock.calls.find(
([{ path }]) => path === '/internal/security/login'
)!;
routeConfig = loginRouteConfig;
routeHandler = loginRouteHandler;
});
it('correctly defines route.', async () => {
expect(routeConfig.options).toEqual({ authRequired: false });
expect(routeConfig.validate).toEqual({
body: expect.any(Type),
query: undefined,
params: undefined,
});
const bodyValidator = (routeConfig.validate as any).body as Type<any>;
expect(bodyValidator.validate({ username: 'user', password: 'password' })).toEqual({
username: 'user',
password: 'password',
});
expect(() => bodyValidator.validate({})).toThrowErrorMatchingInlineSnapshot(
`"[username]: expected value of type [string] but got [undefined]"`
);
expect(() => bodyValidator.validate({ username: 'user' })).toThrowErrorMatchingInlineSnapshot(
`"[password]: expected value of type [string] but got [undefined]"`
);
expect(() =>
bodyValidator.validate({ password: 'password' })
).toThrowErrorMatchingInlineSnapshot(
`"[username]: expected value of type [string] but got [undefined]"`
);
expect(() =>
bodyValidator.validate({ username: '', password: '' })
).toThrowErrorMatchingInlineSnapshot(
`"[username]: value is [] but it must have a minimum length of [1]."`
);
expect(() =>
bodyValidator.validate({ username: 'user', password: '' })
).toThrowErrorMatchingInlineSnapshot(
`"[password]: value is [] but it must have a minimum length of [1]."`
);
expect(() =>
bodyValidator.validate({ username: '', password: 'password' })
).toThrowErrorMatchingInlineSnapshot(
`"[username]: value is [] but it must have a minimum length of [1]."`
);
});
it('returns 500 if authentication throws unhandled exception.', async () => {
const unhandledException = new Error('Something went wrong.');
authc.login.mockRejectedValue(unhandledException);
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(500);
expect(response.payload).toEqual(unhandledException);
expect(authc.login).toHaveBeenCalledWith(mockRequest, {
provider: 'basic',
value: { username: 'user', password: 'password' },
});
});
it('returns 401 if authentication fails.', async () => {
const failureReason = new Error('Something went wrong.');
authc.login.mockResolvedValue(AuthenticationResult.failed(failureReason));
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(401);
expect(response.payload).toEqual(failureReason);
expect(authc.login).toHaveBeenCalledWith(mockRequest, {
provider: 'basic',
value: { username: 'user', password: 'password' },
});
});
it('returns 401 if authentication is not handled.', async () => {
authc.login.mockResolvedValue(AuthenticationResult.notHandled());
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(401);
expect(response.payload).toEqual('Unauthorized');
expect(authc.login).toHaveBeenCalledWith(mockRequest, {
provider: 'basic',
value: { username: 'user', password: 'password' },
});
});
describe('authentication succeeds', () => {
it(`returns user data`, async () => {
authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser()));
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(204);
expect(response.payload).toBeUndefined();
expect(authc.login).toHaveBeenCalledWith(mockRequest, {
provider: 'basic',
value: { username: 'user', password: 'password' },
});
});
});
});
});

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema } from '@kbn/config-schema';
import { wrapIntoCustomErrorResponse } from '../../errors';
import { createLicensedRouteHandler } from '../licensed_route_handler';
import { RouteDefinitionParams } from '..';
/**
* Defines routes required for Basic/Token authentication.
*/
export function defineBasicRoutes({ router, authc, config }: RouteDefinitionParams) {
router.post(
{
path: '/internal/security/login',
validate: {
body: schema.object({
username: schema.string({ minLength: 1 }),
password: schema.string({ minLength: 1 }),
}),
},
options: { authRequired: false },
},
createLicensedRouteHandler(async (context, request, response) => {
const { username, password } = request.body;
try {
// We should prefer `token` over `basic` if possible.
const providerToLoginWith = config.authc.providers.includes('token') ? 'token' : 'basic';
const authenticationResult = await authc.login(request, {
provider: providerToLoginWith,
value: { username, password },
});
if (!authenticationResult.succeeded()) {
return response.unauthorized({ body: authenticationResult.error });
}
return response.noContent();
} catch (error) {
return response.customError(wrapIntoCustomErrorResponse(error));
}
})
);
}

View file

@ -0,0 +1,202 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Type } from '@kbn/config-schema';
import {
IRouter,
kibanaResponseFactory,
RequestHandler,
RequestHandlerContext,
RouteConfig,
} from '../../../../../../src/core/server';
import { LICENSE_CHECK_STATE } from '../../../../licensing/server';
import { Authentication, DeauthenticationResult } from '../../authentication';
import { ConfigType } from '../../config';
import { LegacyAPI } from '../../plugin';
import { defineCommonRoutes } from './common';
import {
elasticsearchServiceMock,
httpServerMock,
httpServiceMock,
loggingServiceMock,
} from '../../../../../../src/core/server/mocks';
import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock';
import { authenticationMock } from '../../authentication/index.mock';
import { authorizationMock } from '../../authorization/index.mock';
describe('Common authentication routes', () => {
let router: jest.Mocked<IRouter>;
let authc: jest.Mocked<Authentication>;
let mockContext: RequestHandlerContext;
beforeEach(() => {
router = httpServiceMock.createRouter();
authc = authenticationMock.create();
mockContext = ({
licensing: {
license: { check: jest.fn().mockReturnValue({ check: LICENSE_CHECK_STATE.Valid }) },
},
} as unknown) as RequestHandlerContext;
defineCommonRoutes({
router,
clusterClient: elasticsearchServiceMock.createClusterClient(),
basePath: httpServiceMock.createBasePath(),
logger: loggingServiceMock.create().get(),
config: { authc: { providers: ['saml'] } } as ConfigType,
authc,
authz: authorizationMock.create(),
getLegacyAPI: () => ({ cspRules: 'test-csp-rule' } as LegacyAPI),
});
});
describe('logout', () => {
let routeHandler: RequestHandler<any, any, any>;
let routeConfig: RouteConfig<any, any, any, any>;
const mockRequest = httpServerMock.createKibanaRequest({
body: { username: 'user', password: 'password' },
});
beforeEach(() => {
const [loginRouteConfig, loginRouteHandler] = router.get.mock.calls.find(
([{ path }]) => path === '/api/security/logout'
)!;
routeConfig = loginRouteConfig;
routeHandler = loginRouteHandler;
});
it('correctly defines route.', async () => {
expect(routeConfig.options).toEqual({ authRequired: false });
expect(routeConfig.validate).toEqual({
body: undefined,
query: expect.any(Type),
params: undefined,
});
const queryValidator = (routeConfig.validate as any).query as Type<any>;
expect(queryValidator.validate({ someRandomField: 'some-random' })).toEqual({
someRandomField: 'some-random',
});
expect(queryValidator.validate({})).toEqual({});
expect(queryValidator.validate(undefined)).toEqual({});
});
it('returns 500 if deauthentication throws unhandled exception.', async () => {
const unhandledException = new Error('Something went wrong.');
authc.logout.mockRejectedValue(unhandledException);
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(500);
expect(response.payload).toEqual(unhandledException);
expect(authc.logout).toHaveBeenCalledWith(mockRequest);
});
it('returns 500 if authenticator fails to logout.', async () => {
const failureReason = new Error('Something went wrong.');
authc.logout.mockResolvedValue(DeauthenticationResult.failed(failureReason));
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(500);
expect(response.payload).toEqual(failureReason);
expect(authc.logout).toHaveBeenCalledWith(mockRequest);
});
it('returns 400 for AJAX requests that can not handle redirect.', async () => {
const mockAjaxRequest = httpServerMock.createKibanaRequest({
headers: { 'kbn-xsrf': 'xsrf' },
});
const response = await routeHandler(mockContext, mockAjaxRequest, kibanaResponseFactory);
expect(response.status).toBe(400);
expect(response.payload).toEqual('Client should be able to process redirect response.');
expect(authc.logout).not.toHaveBeenCalled();
});
it('redirects user to the URL returned by authenticator.', async () => {
authc.logout.mockResolvedValue(DeauthenticationResult.redirectTo('https://custom.logout'));
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(302);
expect(response.payload).toBeUndefined();
expect(response.options).toEqual({ headers: { location: 'https://custom.logout' } });
expect(authc.logout).toHaveBeenCalledWith(mockRequest);
});
it('redirects user to the base path if deauthentication succeeds.', async () => {
authc.logout.mockResolvedValue(DeauthenticationResult.succeeded());
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(302);
expect(response.payload).toBeUndefined();
expect(response.options).toEqual({ headers: { location: '/mock-server-basepath/' } });
expect(authc.logout).toHaveBeenCalledWith(mockRequest);
});
it('redirects user to the base path if deauthentication is not handled.', async () => {
authc.logout.mockResolvedValue(DeauthenticationResult.notHandled());
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(302);
expect(response.payload).toBeUndefined();
expect(response.options).toEqual({ headers: { location: '/mock-server-basepath/' } });
expect(authc.logout).toHaveBeenCalledWith(mockRequest);
});
});
describe('me', () => {
let routeHandler: RequestHandler<any, any, any>;
let routeConfig: RouteConfig<any, any, any, any>;
const mockRequest = httpServerMock.createKibanaRequest({
body: { username: 'user', password: 'password' },
});
beforeEach(() => {
const [loginRouteConfig, loginRouteHandler] = router.get.mock.calls.find(
([{ path }]) => path === '/internal/security/me'
)!;
routeConfig = loginRouteConfig;
routeHandler = loginRouteHandler;
});
it('correctly defines route.', async () => {
expect(routeConfig.options).toBeUndefined();
expect(routeConfig.validate).toBe(false);
});
it('returns 500 if cannot retrieve current user due to unhandled exception.', async () => {
const unhandledException = new Error('Something went wrong.');
authc.getCurrentUser.mockRejectedValue(unhandledException);
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(500);
expect(response.payload).toEqual(unhandledException);
expect(authc.getCurrentUser).toHaveBeenCalledWith(mockRequest);
});
it('returns current user.', async () => {
const mockUser = mockAuthenticatedUser();
authc.getCurrentUser.mockResolvedValue(mockUser);
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(200);
expect(response.payload).toEqual(mockUser);
expect(authc.getCurrentUser).toHaveBeenCalledWith(mockRequest);
});
});
});

View file

@ -0,0 +1,78 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema } from '@kbn/config-schema';
import { canRedirectRequest } from '../../authentication';
import { wrapIntoCustomErrorResponse } from '../../errors';
import { createLicensedRouteHandler } from '../licensed_route_handler';
import { RouteDefinitionParams } from '..';
/**
* Defines routes that are common to various authentication mechanisms.
*/
export function defineCommonRoutes({ router, authc, basePath, logger }: RouteDefinitionParams) {
// Generate two identical routes with new and deprecated URL and issue a warning if route with deprecated URL is ever used.
for (const path of ['/api/security/logout', '/api/security/v1/logout']) {
router.get(
{
path,
// Allow unknown query parameters as this endpoint can be hit by the 3rd-party with any
// set of query string parameters (e.g. SAML/OIDC logout request parameters).
validate: { query: schema.object({}, { allowUnknowns: true }) },
options: { authRequired: false },
},
async (context, request, response) => {
const serverBasePath = basePath.serverBasePath;
if (path === '/api/security/v1/logout') {
logger.warn(
`The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version, please use "${serverBasePath}/api/security/logout" URL instead.`,
{ tags: ['deprecation'] }
);
}
if (!canRedirectRequest(request)) {
return response.badRequest({
body: 'Client should be able to process redirect response.',
});
}
try {
const deauthenticationResult = await authc.logout(request);
if (deauthenticationResult.failed()) {
return response.customError(wrapIntoCustomErrorResponse(deauthenticationResult.error));
}
return response.redirected({
headers: { location: deauthenticationResult.redirectURL || `${serverBasePath}/` },
});
} catch (error) {
return response.customError(wrapIntoCustomErrorResponse(error));
}
}
);
}
// Generate two identical routes with new and deprecated URL and issue a warning if route with deprecated URL is ever used.
for (const path of ['/internal/security/me', '/api/security/v1/me']) {
router.get(
{ path, validate: false },
createLicensedRouteHandler(async (context, request, response) => {
if (path === '/api/security/v1/me') {
logger.warn(
`The "${basePath.serverBasePath}${path}" endpoint is deprecated and will be removed in the next major version.`,
{ tags: ['deprecation'] }
);
}
try {
return response.ok({ body: (await authc.getCurrentUser(request)) as any });
} catch (error) {
return response.customError(wrapIntoCustomErrorResponse(error));
}
})
);
}
}

View file

@ -6,11 +6,39 @@
import { defineSessionRoutes } from './session';
import { defineSAMLRoutes } from './saml';
import { defineBasicRoutes } from './basic';
import { defineCommonRoutes } from './common';
import { defineOIDCRoutes } from './oidc';
import { RouteDefinitionParams } from '..';
export function createCustomResourceResponse(body: string, contentType: string, cspRules: string) {
return {
body,
headers: {
'content-type': contentType,
'cache-control': 'private, no-cache, no-store',
'content-security-policy': cspRules,
},
statusCode: 200,
};
}
export function defineAuthenticationRoutes(params: RouteDefinitionParams) {
defineSessionRoutes(params);
defineCommonRoutes(params);
if (
params.config.authc.providers.includes('basic') ||
params.config.authc.providers.includes('token')
) {
defineBasicRoutes(params);
}
if (params.config.authc.providers.includes('saml')) {
defineSAMLRoutes(params);
}
if (params.config.authc.providers.includes('oidc')) {
defineOIDCRoutes(params);
}
}

View file

@ -0,0 +1,274 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema } from '@kbn/config-schema';
import { i18n } from '@kbn/i18n';
import { KibanaRequest, KibanaResponseFactory } from '../../../../../../src/core/server';
import { OIDCAuthenticationFlow } from '../../authentication';
import { createCustomResourceResponse } from '.';
import { createLicensedRouteHandler } from '../licensed_route_handler';
import { wrapIntoCustomErrorResponse } from '../../errors';
import { ProviderLoginAttempt } from '../../authentication/providers/oidc';
import { RouteDefinitionParams } from '..';
/**
* Defines routes required for SAML authentication.
*/
export function defineOIDCRoutes({
router,
logger,
authc,
getLegacyAPI,
basePath,
}: RouteDefinitionParams) {
// Generate two identical routes with new and deprecated URL and issue a warning if route with deprecated URL is ever used.
for (const path of ['/api/security/oidc/implicit', '/api/security/v1/oidc/implicit']) {
/**
* The route should be configured as a redirect URI in OP when OpenID Connect implicit flow
* is used, so that we can extract authentication response from URL fragment and send it to
* the `/api/security/oidc` route.
*/
router.get(
{
path,
validate: false,
options: { authRequired: false },
},
(context, request, response) => {
const serverBasePath = basePath.serverBasePath;
if (path === '/api/security/v1/oidc/implicit') {
logger.warn(
`The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version, please use "${serverBasePath}/api/security/oidc/implicit" URL instead.`,
{ tags: ['deprecation'] }
);
}
return response.custom(
createCustomResourceResponse(
`
<!DOCTYPE html>
<title>Kibana OpenID Connect Login</title>
<link rel="icon" href="data:,">
<script src="${serverBasePath}/internal/security/oidc/implicit.js"></script>
`,
'text/html',
getLegacyAPI().cspRules
)
);
}
);
}
/**
* The route that accompanies `/api/security/oidc/implicit` and renders a JavaScript snippet
* that extracts fragment part from the URL and send it to the `/api/security/oidc` route.
* We need this separate endpoint because of default CSP policy that forbids inline scripts.
*/
router.get(
{
path: '/internal/security/oidc/implicit.js',
validate: false,
options: { authRequired: false },
},
(context, request, response) => {
const serverBasePath = basePath.serverBasePath;
return response.custom(
createCustomResourceResponse(
`
window.location.replace(
'${serverBasePath}/api/security/oidc?authenticationResponseURI=' + encodeURIComponent(window.location.href)
);
`,
'text/javascript',
getLegacyAPI().cspRules
)
);
}
);
// Generate two identical routes with new and deprecated URL and issue a warning if route with deprecated URL is ever used.
for (const path of ['/api/security/oidc', '/api/security/v1/oidc']) {
router.get(
{
path,
validate: {
query: schema.object(
{
authenticationResponseURI: schema.maybe(schema.uri()),
code: schema.maybe(schema.string()),
error: schema.maybe(schema.string()),
error_description: schema.maybe(schema.string()),
error_uri: schema.maybe(schema.uri()),
iss: schema.maybe(schema.uri({ scheme: ['https'] })),
login_hint: schema.maybe(schema.string()),
target_link_uri: schema.maybe(schema.uri()),
state: schema.maybe(schema.string()),
},
// The client MUST ignore unrecognized response parameters according to
// https://openid.net/specs/openid-connect-core-1_0.html#AuthResponseValidation and
// https://tools.ietf.org/html/rfc6749#section-4.1.2.
{ allowUnknowns: true }
),
},
options: { authRequired: false },
},
createLicensedRouteHandler(async (context, request, response) => {
const serverBasePath = basePath.serverBasePath;
// An HTTP GET request with a query parameter named `authenticationResponseURI` that includes URL fragment OpenID
// Connect Provider sent during implicit authentication flow to the Kibana own proxy page that extracted that URL
// fragment and put it into `authenticationResponseURI` query string parameter for this endpoint. See more details
// at https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth
let loginAttempt: ProviderLoginAttempt | undefined;
if (request.query.authenticationResponseURI) {
loginAttempt = {
flow: OIDCAuthenticationFlow.Implicit,
authenticationResponseURI: request.query.authenticationResponseURI,
};
} else if (request.query.code || request.query.error) {
if (path === '/api/security/v1/oidc') {
logger.warn(
`The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version, please use "${serverBasePath}/api/security/oidc" URL instead.`,
{ tags: ['deprecation'] }
);
}
// An HTTP GET request with a query parameter named `code` (or `error`) as the response to a successful (or
// failed) authentication from an OpenID Connect Provider during authorization code authentication flow.
// See more details at https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth.
loginAttempt = {
flow: OIDCAuthenticationFlow.AuthorizationCode,
// We pass the path only as we can't be sure of the full URL and Elasticsearch doesn't need it anyway.
authenticationResponseURI: request.url.path!,
};
} else if (request.query.iss) {
logger.warn(
`The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version, please use "${serverBasePath}/api/security/oidc/initiate_login" URL for Third-Party Initiated login instead.`,
{ tags: ['deprecation'] }
);
// An HTTP GET request with a query parameter named `iss` as part of a 3rd party initiated authentication.
// See more details at https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin
loginAttempt = {
flow: OIDCAuthenticationFlow.InitiatedBy3rdParty,
iss: request.query.iss,
loginHint: request.query.login_hint,
};
}
if (!loginAttempt) {
return response.badRequest({ body: 'Unrecognized login attempt.' });
}
return performOIDCLogin(request, response, loginAttempt);
})
);
}
// Generate two identical routes with new and deprecated URL and issue a warning if route with deprecated URL is ever used.
for (const path of ['/api/security/oidc/initiate_login', '/api/security/v1/oidc']) {
/**
* An HTTP POST request with the payload parameter named `iss` as part of a 3rd party initiated authentication.
* See more details at https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin
*/
router.post(
{
path,
validate: {
body: schema.object(
{
iss: schema.uri({ scheme: ['https'] }),
login_hint: schema.maybe(schema.string()),
target_link_uri: schema.maybe(schema.uri()),
},
// Other parameters MAY be sent, if defined by extensions. Any parameters used that are not understood MUST
// be ignored by the Client according to https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin.
{ allowUnknowns: true }
),
},
options: { authRequired: false },
},
createLicensedRouteHandler(async (context, request, response) => {
const serverBasePath = basePath.serverBasePath;
if (path === '/api/security/v1/oidc') {
logger.warn(
`The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version, please use "${serverBasePath}/api/security/oidc/initiate_login" URL for Third-Party Initiated login instead.`,
{ tags: ['deprecation'] }
);
}
return performOIDCLogin(request, response, {
flow: OIDCAuthenticationFlow.InitiatedBy3rdParty,
iss: request.body.iss,
loginHint: request.body.login_hint,
});
})
);
}
/**
* An HTTP GET request with the query string parameter named `iss` as part of a 3rd party initiated authentication.
* See more details at https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin
*/
router.get(
{
path: '/api/security/oidc/initiate_login',
validate: {
query: schema.object(
{
iss: schema.uri({ scheme: ['https'] }),
login_hint: schema.maybe(schema.string()),
target_link_uri: schema.maybe(schema.uri()),
},
// Other parameters MAY be sent, if defined by extensions. Any parameters used that are not understood MUST
// be ignored by the Client according to https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin.
{ allowUnknowns: true }
),
},
options: { authRequired: false },
},
createLicensedRouteHandler(async (context, request, response) => {
return performOIDCLogin(request, response, {
flow: OIDCAuthenticationFlow.InitiatedBy3rdParty,
iss: request.query.iss,
loginHint: request.query.login_hint,
});
})
);
async function performOIDCLogin(
request: KibanaRequest,
response: KibanaResponseFactory,
loginAttempt: ProviderLoginAttempt
) {
try {
// We handle the fact that the user might get redirected to Kibana while already having a session
// Return an error notifying the user they are already logged in.
const authenticationResult = await authc.login(request, {
provider: 'oidc',
value: loginAttempt,
});
if (authenticationResult.succeeded()) {
return response.forbidden({
body: i18n.translate('xpack.security.conflictingSessionError', {
defaultMessage:
'Sorry, you already have an active Kibana session. ' +
'If you want to start a new one, please logout from the existing session first.',
}),
});
}
if (authenticationResult.redirected()) {
return response.redirected({
headers: { location: authenticationResult.redirectURL! },
});
}
return response.unauthorized({ body: authenticationResult.error });
} catch (error) {
return response.customError(wrapIntoCustomErrorResponse(error));
}
}
}

View file

@ -6,6 +6,7 @@
import { schema } from '@kbn/config-schema';
import { SAMLLoginStep } from '../../authentication';
import { createCustomResourceResponse } from '.';
import { RouteDefinitionParams } from '..';
/**
@ -18,18 +19,6 @@ export function defineSAMLRoutes({
getLegacyAPI,
basePath,
}: RouteDefinitionParams) {
function createCustomResourceResponse(body: string, contentType: string) {
return {
body,
headers: {
'content-type': contentType,
'cache-control': 'private, no-cache, no-store',
'content-security-policy': getLegacyAPI().cspRules,
},
statusCode: 200,
};
}
router.get(
{
path: '/api/security/saml/capture-url-fragment',
@ -46,7 +35,8 @@ export function defineSAMLRoutes({
<link rel="icon" href="data:,">
<script src="${basePath.serverBasePath}/api/security/saml/capture-url-fragment.js"></script>
`,
'text/html'
'text/html',
getLegacyAPI().cspRules
)
);
}
@ -66,7 +56,8 @@ export function defineSAMLRoutes({
'${basePath.serverBasePath}/api/security/saml/start?redirectURLFragment=' + encodeURIComponent(window.location.hash)
);
`,
'text/javascript'
'text/javascript',
getLegacyAPI().cspRules
)
);
}

View file

@ -81,7 +81,7 @@ describe('GET privileges', () => {
};
describe('failure', () => {
getPrivilegesTest(`returns result of routePreCheckLicense`, {
getPrivilegesTest('returns result of license checker', {
licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' },
asserts: { statusCode: 403, result: { message: 'test forbidden message' } },
});

View file

@ -73,16 +73,18 @@ describe('DELETE role', () => {
};
describe('failure', () => {
deleteRoleTest(`returns result of license checker`, {
deleteRoleTest('returns result of license checker', {
name: 'foo-role',
licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' },
asserts: { statusCode: 403, result: { message: 'test forbidden message' } },
});
const error = Boom.notFound('test not found message');
deleteRoleTest(`returns error from cluster client`, {
deleteRoleTest('returns error from cluster client', {
name: 'foo-role',
apiResponse: () => Promise.reject(error),
apiResponse: async () => {
throw error;
},
asserts: { statusCode: 404, result: error },
});
});

View file

@ -7,7 +7,7 @@
import { schema } from '@kbn/config-schema';
import { RouteDefinitionParams } from '../../index';
import { createLicensedRouteHandler } from '../../licensed_route_handler';
import { wrapError } from '../../../errors';
import { wrapIntoCustomErrorResponse } from '../../../errors';
export function defineDeleteRolesRoutes({ router, clusterClient }: RouteDefinitionParams) {
router.delete(
@ -23,11 +23,7 @@ export function defineDeleteRolesRoutes({ router, clusterClient }: RouteDefiniti
return response.noContent();
} catch (error) {
const wrappedError = wrapError(error);
return response.customError({
body: wrappedError,
statusCode: wrappedError.output.statusCode,
});
return response.customError(wrapIntoCustomErrorResponse(error));
}
})
);

View file

@ -75,15 +75,17 @@ describe('GET role', () => {
};
describe('failure', () => {
getRoleTest(`returns result of license check`, {
getRoleTest('returns result of license checker', {
licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' },
asserts: { statusCode: 403, result: { message: 'test forbidden message' } },
});
const error = Boom.notAcceptable('test not acceptable message');
getRoleTest(`returns error from cluster client`, {
getRoleTest('returns error from cluster client', {
name: 'first_role',
apiResponse: () => Promise.reject(error),
apiResponse: async () => {
throw error;
},
asserts: { statusCode: 406, result: error },
});

View file

@ -7,7 +7,7 @@
import { schema } from '@kbn/config-schema';
import { RouteDefinitionParams } from '../..';
import { createLicensedRouteHandler } from '../../licensed_route_handler';
import { wrapError } from '../../../errors';
import { wrapIntoCustomErrorResponse } from '../../../errors';
import { transformElasticsearchRoleToRole } from './model';
export function defineGetRolesRoutes({ router, authz, clusterClient }: RouteDefinitionParams) {
@ -35,11 +35,7 @@ export function defineGetRolesRoutes({ router, authz, clusterClient }: RouteDefi
return response.notFound();
} catch (error) {
const wrappedError = wrapError(error);
return response.customError({
body: wrappedError,
statusCode: wrappedError.output.statusCode,
});
return response.customError(wrapIntoCustomErrorResponse(error));
}
})
);

View file

@ -67,14 +67,16 @@ describe('GET all roles', () => {
};
describe('failure', () => {
getRolesTest(`returns result of license check`, {
getRolesTest('returns result of license checker', {
licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' },
asserts: { statusCode: 403, result: { message: 'test forbidden message' } },
});
const error = Boom.notAcceptable('test not acceptable message');
getRolesTest(`returns error from cluster client`, {
apiResponse: () => Promise.reject(error),
getRolesTest('returns error from cluster client', {
apiResponse: async () => {
throw error;
},
asserts: { statusCode: 406, result: error },
});

View file

@ -6,7 +6,7 @@
import { RouteDefinitionParams } from '../..';
import { createLicensedRouteHandler } from '../../licensed_route_handler';
import { wrapError } from '../../../errors';
import { wrapIntoCustomErrorResponse } from '../../../errors';
import { ElasticsearchRole, transformElasticsearchRoleToRole } from './model';
export function defineGetAllRolesRoutes({ router, authz, clusterClient }: RouteDefinitionParams) {
@ -37,11 +37,7 @@ export function defineGetAllRolesRoutes({ router, authz, clusterClient }: RouteD
}),
});
} catch (error) {
const wrappedError = wrapError(error);
return response.customError({
body: wrappedError,
statusCode: wrappedError.output.statusCode,
});
return response.customError(wrapIntoCustomErrorResponse(error));
}
})
);

View file

@ -138,7 +138,7 @@ describe('PUT role', () => {
});
describe('failure', () => {
putRoleTest(`returns result of license checker`, {
putRoleTest('returns result of license checker', {
name: 'foo-role',
licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' },
asserts: { statusCode: 403, result: { message: 'test forbidden message' } },

View file

@ -7,7 +7,7 @@
import { schema } from '@kbn/config-schema';
import { RouteDefinitionParams } from '../../index';
import { createLicensedRouteHandler } from '../../licensed_route_handler';
import { wrapError } from '../../../errors';
import { wrapIntoCustomErrorResponse } from '../../../errors';
import {
ElasticsearchRole,
getPutPayloadSchema,
@ -52,11 +52,7 @@ export function definePutRolesRoutes({ router, authz, clusterClient }: RouteDefi
return response.noContent();
} catch (error) {
const wrappedError = wrapError(error);
return response.customError({
body: wrappedError,
statusCode: wrappedError.output.statusCode,
});
return response.customError(wrapIntoCustomErrorResponse(error));
}
})
);

View file

@ -12,6 +12,9 @@ import { LegacyAPI } from '../plugin';
import { defineAuthenticationRoutes } from './authentication';
import { defineAuthorizationRoutes } from './authorization';
import { defineApiKeysRoutes } from './api_keys';
import { defineIndicesRoutes } from './indices';
import { defineUsersRoutes } from './users';
/**
* Describes parameters used to define HTTP routes.
@ -30,4 +33,7 @@ export interface RouteDefinitionParams {
export function defineRoutes(params: RouteDefinitionParams) {
defineAuthenticationRoutes(params);
defineAuthorizationRoutes(params);
defineApiKeysRoutes(params);
defineIndicesRoutes(params);
defineUsersRoutes(params);
}

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema } from '@kbn/config-schema';
import { RouteDefinitionParams } from '../index';
import { wrapIntoCustomErrorResponse } from '../../errors';
export function defineGetFieldsRoutes({ router, clusterClient }: RouteDefinitionParams) {
router.get(
{
path: '/internal/security/fields/{query}',
validate: { params: schema.object({ query: schema.string() }) },
},
async (context, request, response) => {
try {
const indexMappings = (await clusterClient
.asScoped(request)
.callAsCurrentUser('indices.getFieldMapping', {
index: request.params.query,
fields: '*',
allowNoIndices: false,
includeDefaults: true,
})) as Record<string, { mappings: Record<string, unknown> }>;
// The flow is the following (see response format at https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-get-field-mapping.html):
// 1. Iterate over all matched indices.
// 2. Extract all the field names from the `mappings` field of the particular index.
// 3. Collect and flatten the list of the field names.
// 4. Use `Set` to get only unique field names.
return response.ok({
body: Array.from(
new Set(
Object.values(indexMappings)
.map(indexMapping => Object.keys(indexMapping.mappings))
.flat()
)
),
});
} catch (error) {
return response.customError(wrapIntoCustomErrorResponse(error));
}
}
);
}

View file

@ -4,4 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const INTERNAL_API_BASE_PATH = '/internal/security';
import { defineGetFieldsRoutes } from './get_fields';
import { RouteDefinitionParams } from '..';
export function defineIndicesRoutes(params: RouteDefinitionParams) {
defineGetFieldsRoutes(params);
}

View file

@ -0,0 +1,207 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ObjectType } from '@kbn/config-schema';
import {
IClusterClient,
IRouter,
IScopedClusterClient,
kibanaResponseFactory,
RequestHandler,
RequestHandlerContext,
RouteConfig,
} from '../../../../../../src/core/server';
import { LICENSE_CHECK_STATE } from '../../../../licensing/server';
import { Authentication, AuthenticationResult } from '../../authentication';
import { ConfigType } from '../../config';
import { LegacyAPI } from '../../plugin';
import { defineChangeUserPasswordRoutes } from './change_password';
import {
elasticsearchServiceMock,
loggingServiceMock,
httpServiceMock,
httpServerMock,
} from '../../../../../../src/core/server/mocks';
import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock';
import { authorizationMock } from '../../authorization/index.mock';
import { authenticationMock } from '../../authentication/index.mock';
describe('Change password', () => {
let router: jest.Mocked<IRouter>;
let authc: jest.Mocked<Authentication>;
let mockClusterClient: jest.Mocked<IClusterClient>;
let mockScopedClusterClient: jest.Mocked<IScopedClusterClient>;
let routeHandler: RequestHandler<any, any, any>;
let routeConfig: RouteConfig<any, any, any, any>;
let mockContext: RequestHandlerContext;
function checkPasswordChangeAPICall(
username: string,
request: ReturnType<typeof httpServerMock.createKibanaRequest>
) {
expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1);
expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request);
expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1);
expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith(
'shield.changePassword',
{ username, body: { password: 'new-password' } }
);
}
beforeEach(() => {
router = httpServiceMock.createRouter();
authc = authenticationMock.create();
authc.getCurrentUser.mockResolvedValue(mockAuthenticatedUser({ username: 'user' }));
authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser()));
mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
mockClusterClient = elasticsearchServiceMock.createClusterClient();
mockClusterClient.asScoped.mockReturnValue(mockScopedClusterClient);
mockContext = ({
licensing: {
license: { check: jest.fn().mockReturnValue({ check: LICENSE_CHECK_STATE.Valid }) },
},
} as unknown) as RequestHandlerContext;
defineChangeUserPasswordRoutes({
router,
clusterClient: mockClusterClient,
basePath: httpServiceMock.createBasePath(),
logger: loggingServiceMock.create().get(),
config: { authc: { providers: ['saml'] } } as ConfigType,
authc,
authz: authorizationMock.create(),
getLegacyAPI: () => ({ cspRules: 'test-csp-rule' } as LegacyAPI),
});
const [changePasswordRouteConfig, changePasswordRouteHandler] = router.post.mock.calls[0];
routeConfig = changePasswordRouteConfig;
routeHandler = changePasswordRouteHandler;
});
it('correctly defines route.', async () => {
expect(routeConfig.path).toBe('/internal/security/users/{username}/password');
const paramsSchema = (routeConfig.validate as any).params as ObjectType;
expect(() => paramsSchema.validate({})).toThrowErrorMatchingInlineSnapshot(
`"[username]: expected value of type [string] but got [undefined]"`
);
expect(() => paramsSchema.validate({ username: '' })).toThrowErrorMatchingInlineSnapshot(
`"[username]: value is [] but it must have a minimum length of [1]."`
);
expect(() =>
paramsSchema.validate({ username: 'a'.repeat(1025) })
).toThrowErrorMatchingInlineSnapshot(
`"[username]: value is [aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa] but it must have a maximum length of [1024]."`
);
const bodySchema = (routeConfig.validate as any).body as ObjectType;
expect(() => bodySchema.validate({})).toThrowErrorMatchingInlineSnapshot(
`"[newPassword]: expected value of type [string] but got [undefined]"`
);
expect(() => bodySchema.validate({ newPassword: '' })).toThrowErrorMatchingInlineSnapshot(
`"[newPassword]: value is [] but it must have a minimum length of [1]."`
);
expect(() =>
bodySchema.validate({ newPassword: '123456', password: '' })
).toThrowErrorMatchingInlineSnapshot(
`"[password]: value is [] but it must have a minimum length of [1]."`
);
});
describe('own password', () => {
const username = 'user';
const mockRequest = httpServerMock.createKibanaRequest({
params: { username },
body: { password: 'old-password', newPassword: 'new-password' },
});
it('returns 403 if old password is wrong.', async () => {
const loginFailureReason = new Error('Something went wrong.');
authc.login.mockResolvedValue(AuthenticationResult.failed(loginFailureReason));
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(403);
expect(response.payload).toEqual(loginFailureReason);
expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled();
});
it(`returns 401 if user can't authenticate with new password.`, async () => {
const loginFailureReason = new Error('Something went wrong.');
authc.login.mockImplementation(async (request, attempt) => {
const credentials = attempt.value as { username: string; password: string };
if (credentials.username === 'user' && credentials.password === 'new-password') {
return AuthenticationResult.failed(loginFailureReason);
}
return AuthenticationResult.succeeded(mockAuthenticatedUser());
});
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(401);
expect(response.payload).toEqual(loginFailureReason);
checkPasswordChangeAPICall(username, mockRequest);
});
it('returns 500 if password update request fails.', async () => {
const failureReason = new Error('Request failed.');
mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason);
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(500);
expect(response.payload).toEqual(failureReason);
checkPasswordChangeAPICall(username, mockRequest);
});
it('successfully changes own password if provided old password is correct.', async () => {
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(204);
expect(response.payload).toBeUndefined();
checkPasswordChangeAPICall(username, mockRequest);
});
});
describe('other user password', () => {
const username = 'target-user';
const mockRequest = httpServerMock.createKibanaRequest({
params: { username },
body: { newPassword: 'new-password' },
});
it('returns 500 if password update request fails.', async () => {
const failureReason = new Error('Request failed.');
mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason);
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(500);
expect(response.payload).toEqual(failureReason);
expect(authc.login).not.toHaveBeenCalled();
checkPasswordChangeAPICall(username, mockRequest);
});
it('successfully changes user password.', async () => {
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(response.status).toBe(204);
expect(response.payload).toBeUndefined();
expect(authc.login).not.toHaveBeenCalled();
checkPasswordChangeAPICall(username, mockRequest);
});
});
});

View file

@ -0,0 +1,80 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema } from '@kbn/config-schema';
import { wrapIntoCustomErrorResponse } from '../../errors';
import { createLicensedRouteHandler } from '../licensed_route_handler';
import { RouteDefinitionParams } from '..';
export function defineChangeUserPasswordRoutes({
authc,
router,
clusterClient,
config,
}: RouteDefinitionParams) {
router.post(
{
path: '/internal/security/users/{username}/password',
validate: {
params: schema.object({ username: schema.string({ minLength: 1, maxLength: 1024 }) }),
body: schema.object({
password: schema.maybe(schema.string({ minLength: 1 })),
newPassword: schema.string({ minLength: 1 }),
}),
},
},
createLicensedRouteHandler(async (context, request, response) => {
const username = request.params.username;
const { password, newPassword } = request.body;
const isCurrentUser = username === (await authc.getCurrentUser(request))!.username;
// We should prefer `token` over `basic` if possible.
const providerToLoginWith = config.authc.providers.includes('token') ? 'token' : 'basic';
// If user tries to change own password, let's check if old password is valid first by trying
// to login.
if (isCurrentUser) {
try {
const authenticationResult = await authc.login(request, {
provider: providerToLoginWith,
value: { username, password },
// We shouldn't alter authentication state just yet.
stateless: true,
});
if (!authenticationResult.succeeded()) {
return response.forbidden({ body: authenticationResult.error });
}
} catch (error) {
return response.customError(wrapIntoCustomErrorResponse(error));
}
}
try {
await clusterClient.asScoped(request).callAsCurrentUser('shield.changePassword', {
username,
body: { password: newPassword },
});
// Now we authenticate user with the new password again updating current session if any.
if (isCurrentUser) {
const authenticationResult = await authc.login(request, {
provider: providerToLoginWith,
value: { username, password: newPassword },
});
if (!authenticationResult.succeeded()) {
return response.unauthorized({ body: authenticationResult.error });
}
}
return response.noContent();
} catch (error) {
return response.customError(wrapIntoCustomErrorResponse(error));
}
})
);
}

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema } from '@kbn/config-schema';
import { wrapIntoCustomErrorResponse } from '../../errors';
import { createLicensedRouteHandler } from '../licensed_route_handler';
import { RouteDefinitionParams } from '..';
export function defineCreateOrUpdateUserRoutes({ router, clusterClient }: RouteDefinitionParams) {
router.post(
{
path: '/internal/security/users/{username}',
validate: {
params: schema.object({ username: schema.string({ minLength: 1, maxLength: 1024 }) }),
body: schema.object({
username: schema.string({ minLength: 1, maxLength: 1024 }),
password: schema.maybe(schema.string({ minLength: 1 })),
roles: schema.arrayOf(schema.string({ minLength: 1 })),
full_name: schema.maybe(schema.oneOf([schema.literal(null), schema.string()])),
email: schema.maybe(schema.oneOf([schema.literal(null), schema.string()])),
metadata: schema.maybe(schema.recordOf(schema.string(), schema.any())),
enabled: schema.boolean({ defaultValue: true }),
}),
},
},
createLicensedRouteHandler(async (context, request, response) => {
try {
await clusterClient.asScoped(request).callAsCurrentUser('shield.putUser', {
username: request.params.username,
// Omit `username`, `enabled` and all fields with `null` value.
body: Object.fromEntries(
Object.entries(request.body).filter(
([key, value]) => value !== null && key !== 'enabled' && key !== 'username'
)
),
});
return response.ok({ body: request.body });
} catch (error) {
return response.customError(wrapIntoCustomErrorResponse(error));
}
})
);
}

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema } from '@kbn/config-schema';
import { RouteDefinitionParams } from '../index';
import { wrapIntoCustomErrorResponse } from '../../errors';
import { createLicensedRouteHandler } from '../licensed_route_handler';
export function defineDeleteUserRoutes({ router, clusterClient }: RouteDefinitionParams) {
router.delete(
{
path: '/internal/security/users/{username}',
validate: {
params: schema.object({ username: schema.string({ minLength: 1, maxLength: 1024 }) }),
},
},
createLicensedRouteHandler(async (context, request, response) => {
try {
await clusterClient
.asScoped(request)
.callAsCurrentUser('shield.deleteUser', { username: request.params.username });
return response.noContent();
} catch (error) {
return response.customError(wrapIntoCustomErrorResponse(error));
}
})
);
}

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema } from '@kbn/config-schema';
import { wrapIntoCustomErrorResponse } from '../../errors';
import { createLicensedRouteHandler } from '../licensed_route_handler';
import { RouteDefinitionParams } from '..';
export function defineGetUserRoutes({ router, clusterClient }: RouteDefinitionParams) {
router.get(
{
path: '/internal/security/users/{username}',
validate: {
params: schema.object({ username: schema.string({ minLength: 1, maxLength: 1024 }) }),
},
},
createLicensedRouteHandler(async (context, request, response) => {
try {
const username = request.params.username;
const users = (await clusterClient
.asScoped(request)
.callAsCurrentUser('shield.getUser', { username })) as Record<string, {}>;
if (!users[username]) {
return response.notFound();
}
return response.ok({ body: users[username] });
} catch (error) {
return response.customError(wrapIntoCustomErrorResponse(error));
}
})
);
}

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { RouteDefinitionParams } from '../index';
import { wrapIntoCustomErrorResponse } from '../../errors';
import { createLicensedRouteHandler } from '../licensed_route_handler';
export function defineGetAllUsersRoutes({ router, clusterClient }: RouteDefinitionParams) {
router.get(
{ path: '/internal/security/users', validate: false },
createLicensedRouteHandler(async (context, request, response) => {
try {
return response.ok({
// Return only values since keys (user names) are already duplicated there.
body: Object.values(
await clusterClient.asScoped(request).callAsCurrentUser('shield.getUser')
),
});
} catch (error) {
return response.customError(wrapIntoCustomErrorResponse(error));
}
})
);
}

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { RouteDefinitionParams } from '../index';
import { defineGetUserRoutes } from './get';
import { defineGetAllUsersRoutes } from './get_all';
import { defineCreateOrUpdateUserRoutes } from './create_or_update';
import { defineDeleteUserRoutes } from './delete';
import { defineChangeUserPasswordRoutes } from './change_password';
export function defineUsersRoutes(params: RouteDefinitionParams) {
defineGetUserRoutes(params);
defineGetAllUsersRoutes(params);
defineCreateOrUpdateUserRoutes(params);
defineDeleteUserRoutes(params);
defineChangeUserPasswordRoutes(params);
}

View file

@ -32,7 +32,7 @@ export default function ({ getService }) {
it('should reject API requests if client is not authenticated', async () => {
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.expect(401);
});
@ -41,24 +41,24 @@ export default function ({ getService }) {
const wrongUsername = `wrong-${validUsername}`;
const wrongPassword = `wrong-${validPassword}`;
await supertest.post('/api/security/v1/login')
await supertest.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({ username: wrongUsername, password: wrongPassword })
.expect(401);
await supertest.post('/api/security/v1/login')
await supertest.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({ username: validUsername, password: wrongPassword })
.expect(401);
await supertest.post('/api/security/v1/login')
await supertest.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({ username: wrongUsername, password: validPassword })
.expect(401);
});
it('should set authentication cookie for login with valid credentials', async () => {
const loginResponse = await supertest.post('/api/security/v1/login')
const loginResponse = await supertest.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({ username: validUsername, password: validPassword })
.expect(204);
@ -77,17 +77,17 @@ export default function ({ getService }) {
const wrongUsername = `wrong-${validUsername}`;
const wrongPassword = `wrong-${validPassword}`;
await supertest.get('/api/security/v1/me')
await supertest.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Authorization', `Basic ${Buffer.from(`${wrongUsername}:${wrongPassword}`).toString('base64')}`)
.expect(401);
await supertest.get('/api/security/v1/me')
await supertest.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Authorization', `Basic ${Buffer.from(`${validUsername}:${wrongPassword}`).toString('base64')}`)
.expect(401);
await supertest.get('/api/security/v1/me')
await supertest.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Authorization', `Basic ${Buffer.from(`${wrongUsername}:${validPassword}`).toString('base64')}`)
.expect(401);
@ -95,7 +95,7 @@ export default function ({ getService }) {
it('should allow access to the API with valid credentials in the header', async () => {
const apiResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Authorization', `Basic ${Buffer.from(`${validUsername}:${validPassword}`).toString('base64')}`)
.expect(200);
@ -116,7 +116,7 @@ export default function ({ getService }) {
describe('with session cookie', () => {
let sessionCookie;
beforeEach(async () => {
const loginResponse = await supertest.post('/api/security/v1/login')
const loginResponse = await supertest.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({ username: validUsername, password: validPassword })
.expect(204);
@ -128,12 +128,12 @@ export default function ({ getService }) {
// There is no session cookie provided and no server side session should have
// been established, so request should be rejected.
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.expect(401);
const apiResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
@ -153,7 +153,7 @@ export default function ({ getService }) {
it('should extend cookie on every successful non-system API call', async () => {
const apiResponseOne = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
@ -165,7 +165,7 @@ export default function ({ getService }) {
expect(sessionCookieOne.value).to.not.equal(sessionCookie.value);
const apiResponseTwo = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
@ -179,7 +179,7 @@ export default function ({ getService }) {
it('should not extend cookie for system API calls', async () => {
const systemAPIResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('kbn-system-api', 'true')
.set('Cookie', sessionCookie.cookieString())
@ -190,7 +190,7 @@ export default function ({ getService }) {
it('should fail and preserve session cookie if unsupported authentication schema is used', async () => {
const apiResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Authorization', 'Bearer AbCdEf')
.set('Cookie', sessionCookie.cookieString())
@ -200,7 +200,7 @@ export default function ({ getService }) {
});
it('should clear cookie on logout and redirect to login', async ()=> {
const logoutResponse = await supertest.get('/api/security/v1/logout?next=%2Fabc%2Fxyz&msg=test')
const logoutResponse = await supertest.get('/api/security/logout?next=%2Fabc%2Fxyz&msg=test')
.set('Cookie', sessionCookie.cookieString())
.expect(302);
@ -256,7 +256,7 @@ export default function ({ getService }) {
});
it('should redirect to home page if cookie is not provided', async ()=> {
const logoutResponse = await supertest.get('/api/security/v1/logout')
const logoutResponse = await supertest.get('/api/security/logout')
.expect(302);
expect(logoutResponse.headers['set-cookie']).to.be(undefined);

View file

@ -20,7 +20,7 @@ export default function({ getService }: FtrProviderContext) {
await security.user.create(mockUserName, { password: mockUserPassword, roles: [] });
const loginResponse = await supertest
.post('/api/security/v1/login')
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({ username: mockUserName, password: mockUserPassword })
.expect(204);
@ -34,7 +34,7 @@ export default function({ getService }: FtrProviderContext) {
const newPassword = `xxx-${mockUserPassword}-xxx`;
await supertest
.post(`/api/security/v1/users/${mockUserName}/password`)
.post(`/internal/security/users/${mockUserName}/password`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.send({ password: wrongPassword, newPassword })
@ -42,21 +42,21 @@ export default function({ getService }: FtrProviderContext) {
// Let's check that we can't login with wrong password, just in case.
await supertest
.post('/api/security/v1/login')
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({ username: mockUserName, password: wrongPassword })
.expect(401);
// Let's check that we can't login with the password we were supposed to set.
await supertest
.post('/api/security/v1/login')
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({ username: mockUserName, password: newPassword })
.expect(401);
// And can login with the current password.
await supertest
.post('/api/security/v1/login')
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({ username: mockUserName, password: mockUserPassword })
.expect(204);
@ -66,7 +66,7 @@ export default function({ getService }: FtrProviderContext) {
const newPassword = `xxx-${mockUserPassword}-xxx`;
const passwordChangeResponse = await supertest
.post(`/api/security/v1/users/${mockUserName}/password`)
.post(`/internal/security/users/${mockUserName}/password`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.send({ password: mockUserPassword, newPassword })
@ -76,28 +76,28 @@ export default function({ getService }: FtrProviderContext) {
// Let's check that previous cookie isn't valid anymore.
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(401);
// And that we can't login with the old password.
await supertest
.post('/api/security/v1/login')
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({ username: mockUserName, password: mockUserPassword })
.expect(401);
// But new cookie should be valid.
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', newSessionCookie.cookieString())
.expect(200);
// And that we can login with new credentials.
await supertest
.post('/api/security/v1/login')
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({ username: mockUserName, password: newPassword })
.expect(204);

View file

@ -11,10 +11,10 @@ export default function({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('Index Fields', () => {
describe('GET /api/security/v1/fields/{query}', () => {
describe('GET /internal/security/fields/{query}', () => {
it('should return a list of available index mapping fields', async () => {
await supertest
.get('/api/security/v1/fields/.kibana')
.get('/internal/security/fields/.kibana')
.set('kbn-xsrf', 'xxx')
.send()
.expect(200)

View file

@ -43,7 +43,7 @@ export default function({ getService }: FtrProviderContext) {
beforeEach(async () => {
await supertest
.post('/api/security/v1/login')
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({ username: validUsername, password: validPassword })
.expect(204)

View file

@ -8,7 +8,7 @@ import { format as formatUrl } from 'url';
import * as legacyElasticsearch from 'elasticsearch';
import shieldPlugin from '../../../legacy/server/lib/esjs_shield_plugin';
import { elasticsearchClientPlugin } from '../../../plugins/security/server/elasticsearch_client_plugin';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { DEFAULT_API_VERSION } from '../../../../src/core/server/elasticsearch/elasticsearch_config';
@ -19,6 +19,6 @@ export function LegacyEsProvider({ getService }) {
apiVersion: DEFAULT_API_VERSION,
host: formatUrl(config.get('servers.elasticsearch')),
requestTimeout: config.get('timeouts.esRequestTimeout'),
plugins: [shieldPlugin],
plugins: [elasticsearchClientPlugin],
});
}

View file

@ -47,7 +47,7 @@ export default function({ getService }: FtrProviderContext) {
it('should reject API requests if client is not authenticated', async () => {
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.expect(401);
});
@ -55,7 +55,7 @@ export default function({ getService }: FtrProviderContext) {
it('does not prevent basic login', async () => {
const [username, password] = config.get('servers.elasticsearch.auth').split(':');
const response = await supertest
.post('/api/security/v1/login')
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({ username, password })
.expect(204);
@ -67,7 +67,7 @@ export default function({ getService }: FtrProviderContext) {
checkCookieIsSet(cookie);
const { body: user } = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', cookie.cookieString())
.expect(200);
@ -98,7 +98,7 @@ export default function({ getService }: FtrProviderContext) {
describe('finishing SPNEGO', () => {
it('should properly set cookie and authenticate user', async () => {
const response = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('Authorization', `Negotiate ${spnegoToken}`)
.expect(200);
@ -114,7 +114,7 @@ export default function({ getService }: FtrProviderContext) {
checkCookieIsSet(sessionCookie);
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200, {
@ -134,7 +134,7 @@ export default function({ getService }: FtrProviderContext) {
it('should re-initiate SPNEGO handshake if token is rejected with 401', async () => {
const spnegoResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('Authorization', `Negotiate ${Buffer.from('Hello').toString('base64')}`)
.expect(401);
expect(spnegoResponse.headers['set-cookie']).to.be(undefined);
@ -143,7 +143,7 @@ export default function({ getService }: FtrProviderContext) {
it('should fail if SPNEGO token is rejected because of unknown reason', async () => {
const spnegoResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('Authorization', 'Negotiate (:I am malformed:)')
.expect(500);
expect(spnegoResponse.headers['set-cookie']).to.be(undefined);
@ -156,7 +156,7 @@ export default function({ getService }: FtrProviderContext) {
beforeEach(async () => {
const response = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('Authorization', `Negotiate ${spnegoToken}`)
.expect(200);
@ -169,7 +169,7 @@ export default function({ getService }: FtrProviderContext) {
it('should extend cookie on every successful non-system API call', async () => {
const apiResponseOne = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
@ -181,7 +181,7 @@ export default function({ getService }: FtrProviderContext) {
expect(sessionCookieOne.value).to.not.equal(sessionCookie.value);
const apiResponseTwo = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
@ -195,7 +195,7 @@ export default function({ getService }: FtrProviderContext) {
it('should not extend cookie for system API calls', async () => {
const systemAPIResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('kbn-system-api', 'true')
.set('Cookie', sessionCookie.cookieString())
@ -206,7 +206,7 @@ export default function({ getService }: FtrProviderContext) {
it('should fail and preserve session cookie if unsupported authentication schema is used', async () => {
const apiResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Authorization', 'Basic a3JiNTprcmI1')
.set('Cookie', sessionCookie.cookieString())
@ -220,7 +220,7 @@ export default function({ getService }: FtrProviderContext) {
it('should redirect to `logged_out` page after successful logout', async () => {
// First authenticate user to retrieve session cookie.
const response = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('Authorization', `Negotiate ${spnegoToken}`)
.expect(200);
@ -232,7 +232,7 @@ export default function({ getService }: FtrProviderContext) {
// And then log user out.
const logoutResponse = await supertest
.get('/api/security/v1/logout')
.get('/api/security/logout')
.set('Cookie', sessionCookie.cookieString())
.expect(302);
@ -245,7 +245,7 @@ export default function({ getService }: FtrProviderContext) {
// Token that was stored in the previous cookie should be invalidated as well and old
// session cookie should not allow API access.
const apiResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(401);
@ -259,7 +259,7 @@ export default function({ getService }: FtrProviderContext) {
});
it('should redirect to home page if session cookie is not provided', async () => {
const logoutResponse = await supertest.get('/api/security/v1/logout').expect(302);
const logoutResponse = await supertest.get('/api/security/logout').expect(302);
expect(logoutResponse.headers['set-cookie']).to.be(undefined);
expect(logoutResponse.headers.location).to.be('/');
@ -271,7 +271,7 @@ export default function({ getService }: FtrProviderContext) {
beforeEach(async () => {
const response = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('Authorization', `Negotiate ${spnegoToken}`)
.expect(200);
@ -292,7 +292,7 @@ export default function({ getService }: FtrProviderContext) {
// This api call should succeed and automatically refresh token. Returned cookie will contain
// the new access and refresh token pair.
const apiResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
@ -305,7 +305,7 @@ export default function({ getService }: FtrProviderContext) {
// The first new cookie with fresh pair of access and refresh tokens should work.
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', refreshedCookie.cookieString())
.expect(200);
@ -335,7 +335,7 @@ export default function({ getService }: FtrProviderContext) {
// The first new cookie with fresh pair of access and refresh tokens should work.
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', refreshedCookie.cookieString())
.expect(200);
@ -349,7 +349,7 @@ export default function({ getService }: FtrProviderContext) {
beforeEach(async () => {
const response = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('Authorization', `Negotiate ${spnegoToken}`)
.expect(200);
@ -374,7 +374,7 @@ export default function({ getService }: FtrProviderContext) {
it('AJAX call should initiate SPNEGO and clear existing cookie', async function() {
const apiResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(401);

View file

@ -16,7 +16,7 @@ export default function ({ getService }) {
describe('OpenID Connect authentication', () => {
it('should reject API requests if client is not authenticated', async () => {
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.expect(401);
});
@ -46,7 +46,8 @@ export default function ({ getService }) {
});
it('should properly set cookie, return all parameters and redirect user for Third Party initiated', async () => {
const handshakeResponse = await supertest.get('/api/security/v1/oidc?iss=https://test-op.elastic.co')
const handshakeResponse = await supertest.post('/api/security/oidc/initiate_login')
.send({ iss: 'https://test-op.elastic.co' })
.expect(302);
const cookies = handshakeResponse.headers['set-cookie'];
@ -74,7 +75,7 @@ export default function ({ getService }) {
const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]);
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(401);
@ -108,20 +109,20 @@ export default function ({ getService }) {
});
it('should fail if OpenID Connect response is not complemented with handshake cookie', async () => {
await supertest.get(`/api/security/v1/oidc?code=thisisthecode&state=${stateAndNonce.state}`)
await supertest.get(`/api/security/oidc?code=thisisthecode&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.expect(401);
});
it('should fail if state is not matching', async () => {
await supertest.get(`/api/security/v1/oidc?code=thisisthecode&state=someothervalue`)
await supertest.get(`/api/security/oidc?code=thisisthecode&state=someothervalue`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(401);
});
it('should succeed if both the OpenID Connect response and the cookie are provided', async () => {
const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`)
const oidcAuthenticationResponse = await supertest.get(`/api/security/oidc?code=code1&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(302);
@ -139,7 +140,7 @@ export default function ({ getService }) {
expect(sessionCookie.httpOnly).to.be(true);
const apiResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
@ -160,7 +161,7 @@ export default function ({ getService }) {
describe('Complete third party initiated authentication', () => {
it('should authenticate a user when a third party initiates the authentication', async () => {
const handshakeResponse = await supertest.get('/api/security/v1/oidc?iss=https://test-op.elastic.co')
const handshakeResponse = await supertest.get('/api/security/oidc/initiate_login?iss=https://test-op.elastic.co')
.expect(302);
const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]);
const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location);
@ -172,7 +173,7 @@ export default function ({ getService }) {
.send({ nonce: stateAndNonce.nonce })
.expect(200);
const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code2&state=${stateAndNonce.state}`)
const oidcAuthenticationResponse = await supertest.get(`/api/security/oidc?code=code2&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(302);
@ -186,7 +187,7 @@ export default function ({ getService }) {
expect(sessionCookie.httpOnly).to.be(true);
const apiResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
@ -222,7 +223,7 @@ export default function ({ getService }) {
.send({ nonce: stateAndNonce.nonce })
.expect(200);
const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`)
const oidcAuthenticationResponse = await supertest.get(`/api/security/oidc?code=code1&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(302);
@ -232,7 +233,7 @@ export default function ({ getService }) {
it('should extend cookie on every successful non-system API call', async () => {
const apiResponseOne = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
@ -244,7 +245,7 @@ export default function ({ getService }) {
expect(sessionCookieOne.value).to.not.equal(sessionCookie.value);
const apiResponseTwo = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
@ -258,7 +259,7 @@ export default function ({ getService }) {
it('should not extend cookie for system API calls', async () => {
const systemAPIResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('kbn-system-api', 'true')
.set('Cookie', sessionCookie.cookieString())
@ -269,7 +270,7 @@ export default function ({ getService }) {
it('should fail and preserve session cookie if unsupported authentication schema is used', async () => {
const apiResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Authorization', 'Basic AbCdEf')
.set('Cookie', sessionCookie.cookieString())
@ -295,7 +296,7 @@ export default function ({ getService }) {
.send({ nonce: stateAndNonce.nonce })
.expect(200);
const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`)
const oidcAuthenticationResponse = await supertest.get(`/api/security/oidc?code=code1&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(302);
@ -307,7 +308,7 @@ export default function ({ getService }) {
});
it('should redirect to home page if session cookie is not provided', async () => {
const logoutResponse = await supertest.get('/api/security/v1/logout')
const logoutResponse = await supertest.get('/api/security/logout')
.expect(302);
expect(logoutResponse.headers['set-cookie']).to.be(undefined);
@ -315,7 +316,7 @@ export default function ({ getService }) {
});
it('should redirect to the OPs endsession endpoint to complete logout', async () => {
const logoutResponse = await supertest.get('/api/security/v1/logout')
const logoutResponse = await supertest.get('/api/security/logout')
.set('Cookie', sessionCookie.cookieString())
.expect(302);
@ -336,7 +337,7 @@ export default function ({ getService }) {
// Tokens that were stored in the previous cookie should be invalidated as well and old
// session cookie should not allow API access.
const apiResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(400);
@ -349,7 +350,7 @@ export default function ({ getService }) {
});
it('should reject AJAX requests', async () => {
const ajaxResponse = await supertest.get('/api/security/v1/logout')
const ajaxResponse = await supertest.get('/api/security/logout')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(400);
@ -379,7 +380,7 @@ export default function ({ getService }) {
.send({ nonce: stateAndNonce.nonce })
.expect(200);
const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`)
const oidcAuthenticationResponse = await supertest.get(`/api/security/oidc?code=code1&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(302);
@ -408,7 +409,7 @@ export default function ({ getService }) {
// This api call should succeed and automatically refresh token. Returned cookie will contain
// the new access and refresh token pair.
const firstResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
@ -422,7 +423,7 @@ export default function ({ getService }) {
// Request with old cookie should reuse the same refresh token if within 60 seconds.
// Returned cookie will contain the same new access and refresh token pairs as the first request
const secondResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
@ -437,14 +438,14 @@ export default function ({ getService }) {
// The first new cookie with fresh pair of access and refresh tokens should work.
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', firstNewCookie.cookieString())
.expect(200);
// The second new cookie with fresh pair of access and refresh tokens should work.
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', secondNewCookie.cookieString())
.expect(200);
@ -467,7 +468,7 @@ export default function ({ getService }) {
.send({ nonce: stateAndNonce.nonce })
.expect(200);
const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`)
const oidcAuthenticationResponse = await supertest.get(`/api/security/oidc?code=code1&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(302);

View file

@ -31,7 +31,7 @@ export default function({ getService }: FtrProviderContext) {
});
it('should return an HTML page that will parse URL fragment', async () => {
const response = await supertest.get('/api/security/v1/oidc/implicit').expect(200);
const response = await supertest.get('/api/security/oidc/implicit').expect(200);
const dom = new JSDOM(response.text, {
url: formatURL({ ...config.get('servers.kibana'), auth: false }),
runScripts: 'dangerously',
@ -44,7 +44,7 @@ export default function({ getService }: FtrProviderContext) {
Object.defineProperty(window, 'location', {
value: {
href:
'https://kibana.com/api/security/v1/oidc/implicit#token=some_token&access_token=some_access_token',
'https://kibana.com/api/security/oidc/implicit#token=some_token&access_token=some_access_token',
replace(newLocation: string) {
this.href = newLocation;
resolve();
@ -66,17 +66,17 @@ export default function({ getService }: FtrProviderContext) {
// Check that script that forwards URL fragment worked correctly.
expect(dom.window.location.href).to.be(
'/api/security/v1/oidc?authenticationResponseURI=https%3A%2F%2Fkibana.com%2Fapi%2Fsecurity%2Fv1%2Foidc%2Fimplicit%23token%3Dsome_token%26access_token%3Dsome_access_token'
'/api/security/oidc?authenticationResponseURI=https%3A%2F%2Fkibana.com%2Fapi%2Fsecurity%2Foidc%2Fimplicit%23token%3Dsome_token%26access_token%3Dsome_access_token'
);
});
it('should fail if OpenID Connect response is not complemented with handshake cookie', async () => {
const { idToken, accessToken } = createTokens('1', stateAndNonce.nonce);
const authenticationResponse = `https://kibana.com/api/security/v1/oidc/implicit#id_token=${idToken}&state=${stateAndNonce.state}&token_type=bearer&access_token=${accessToken}`;
const authenticationResponse = `https://kibana.com/api/security/oidc/implicit#id_token=${idToken}&state=${stateAndNonce.state}&token_type=bearer&access_token=${accessToken}`;
await supertest
.get(
`/api/security/v1/oidc?authenticationResponseURI=${encodeURIComponent(
`/api/security/oidc?authenticationResponseURI=${encodeURIComponent(
authenticationResponse
)}`
)
@ -86,11 +86,11 @@ export default function({ getService }: FtrProviderContext) {
it('should fail if state is not matching', async () => {
const { idToken, accessToken } = createTokens('1', stateAndNonce.nonce);
const authenticationResponse = `https://kibana.com/api/security/v1/oidc/implicit#id_token=${idToken}&state=$someothervalue&token_type=bearer&access_token=${accessToken}`;
const authenticationResponse = `https://kibana.com/api/security/oidc/implicit#id_token=${idToken}&state=$someothervalue&token_type=bearer&access_token=${accessToken}`;
await supertest
.get(
`/api/security/v1/oidc?authenticationResponseURI=${encodeURIComponent(
`/api/security/oidc?authenticationResponseURI=${encodeURIComponent(
authenticationResponse
)}`
)
@ -102,11 +102,11 @@ export default function({ getService }: FtrProviderContext) {
// FLAKY: https://github.com/elastic/kibana/issues/43938
it.skip('should succeed if both the OpenID Connect response and the cookie are provided', async () => {
const { idToken, accessToken } = createTokens('1', stateAndNonce.nonce);
const authenticationResponse = `https://kibana.com/api/security/v1/oidc/implicit#id_token=${idToken}&state=${stateAndNonce.state}&token_type=bearer&access_token=${accessToken}`;
const authenticationResponse = `https://kibana.com/api/security/oidc/implicit#id_token=${idToken}&state=${stateAndNonce.state}&token_type=bearer&access_token=${accessToken}`;
const oidcAuthenticationResponse = await supertest
.get(
`/api/security/v1/oidc?authenticationResponseURI=${encodeURIComponent(
`/api/security/oidc?authenticationResponseURI=${encodeURIComponent(
authenticationResponse
)}`
)
@ -129,7 +129,7 @@ export default function({ getService }: FtrProviderContext) {
expect(sessionCookie.httpOnly).to.be(true);
const apiResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);

View file

@ -32,7 +32,7 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) {
`xpack.security.authc.realms.oidc.oidc1.rp.client_id=0oa8sqpov3TxMWJOt356`,
`xpack.security.authc.realms.oidc.oidc1.rp.client_secret=0oa8sqpov3TxMWJOt356`,
`xpack.security.authc.realms.oidc.oidc1.rp.response_type=code`,
`xpack.security.authc.realms.oidc.oidc1.rp.redirect_uri=http://localhost:${kibanaPort}/api/security/v1/oidc`,
`xpack.security.authc.realms.oidc.oidc1.rp.redirect_uri=http://localhost:${kibanaPort}/api/security/oidc`,
`xpack.security.authc.realms.oidc.oidc1.op.authorization_endpoint=https://test-op.elastic.co/oauth2/v1/authorize`,
`xpack.security.authc.realms.oidc.oidc1.op.endsession_endpoint=https://test-op.elastic.co/oauth2/v1/endsession`,
`xpack.security.authc.realms.oidc.oidc1.op.token_endpoint=http://localhost:${kibanaPort}/api/oidc_provider/token_endpoint`,
@ -52,7 +52,7 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) {
'--xpack.security.authc.oidc.realm="oidc1"',
'--server.xsrf.whitelist',
JSON.stringify([
'/api/security/v1/oidc',
'/api/security/oidc/initiate_login',
'/api/oidc_provider/token_endpoint',
'/api/oidc_provider/userinfo_endpoint',
]),

View file

@ -57,7 +57,7 @@ export default function({ getService }: FtrProviderContext) {
it('should reject API requests that use untrusted certificate', async () => {
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.ca(CA_CERT)
.pfx(UNTRUSTED_CLIENT_CERT)
.set('kbn-xsrf', 'xxx')
@ -67,7 +67,7 @@ export default function({ getService }: FtrProviderContext) {
it('does not prevent basic login', async () => {
const [username, password] = config.get('servers.elasticsearch.auth').split(':');
const response = await supertest
.post('/api/security/v1/login')
.post('/internal/security/login')
.ca(CA_CERT)
.pfx(UNTRUSTED_CLIENT_CERT)
.set('kbn-xsrf', 'xxx')
@ -81,7 +81,7 @@ export default function({ getService }: FtrProviderContext) {
checkCookieIsSet(cookie);
const { body: user } = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.ca(CA_CERT)
.pfx(UNTRUSTED_CLIENT_CERT)
.set('kbn-xsrf', 'xxx')
@ -94,7 +94,7 @@ export default function({ getService }: FtrProviderContext) {
it('should properly set cookie and authenticate user', async () => {
const response = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.ca(CA_CERT)
.pfx(FIRST_CLIENT_CERT)
.expect(200);
@ -122,7 +122,7 @@ export default function({ getService }: FtrProviderContext) {
// Cookie should be accepted.
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.ca(CA_CERT)
.pfx(FIRST_CLIENT_CERT)
.set('Cookie', sessionCookie.cookieString())
@ -131,7 +131,7 @@ export default function({ getService }: FtrProviderContext) {
it('should update session if new certificate is provided', async () => {
let response = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.ca(CA_CERT)
.pfx(FIRST_CLIENT_CERT)
.expect(200);
@ -143,7 +143,7 @@ export default function({ getService }: FtrProviderContext) {
checkCookieIsSet(sessionCookie);
response = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.ca(CA_CERT)
.pfx(SECOND_CLIENT_CERT)
.set('Cookie', sessionCookie.cookieString())
@ -167,7 +167,7 @@ export default function({ getService }: FtrProviderContext) {
it('should reject valid cookie if used with untrusted certificate', async () => {
const response = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.ca(CA_CERT)
.pfx(FIRST_CLIENT_CERT)
.expect(200);
@ -179,7 +179,7 @@ export default function({ getService }: FtrProviderContext) {
checkCookieIsSet(sessionCookie);
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.ca(CA_CERT)
.pfx(UNTRUSTED_CLIENT_CERT)
.set('Cookie', sessionCookie.cookieString())
@ -191,7 +191,7 @@ export default function({ getService }: FtrProviderContext) {
beforeEach(async () => {
const response = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.ca(CA_CERT)
.pfx(FIRST_CLIENT_CERT)
.expect(200);
@ -205,7 +205,7 @@ export default function({ getService }: FtrProviderContext) {
it('should extend cookie on every successful non-system API call', async () => {
const apiResponseOne = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.ca(CA_CERT)
.pfx(FIRST_CLIENT_CERT)
.set('kbn-xsrf', 'xxx')
@ -219,7 +219,7 @@ export default function({ getService }: FtrProviderContext) {
expect(sessionCookieOne.value).to.not.equal(sessionCookie.value);
const apiResponseTwo = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.ca(CA_CERT)
.pfx(FIRST_CLIENT_CERT)
.set('kbn-xsrf', 'xxx')
@ -235,7 +235,7 @@ export default function({ getService }: FtrProviderContext) {
it('should not extend cookie for system API calls', async () => {
const systemAPIResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.ca(CA_CERT)
.pfx(FIRST_CLIENT_CERT)
.set('kbn-xsrf', 'xxx')
@ -248,7 +248,7 @@ export default function({ getService }: FtrProviderContext) {
it('should fail and preserve session cookie if unsupported authentication schema is used', async () => {
const apiResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.ca(CA_CERT)
.pfx(FIRST_CLIENT_CERT)
.set('kbn-xsrf', 'xxx')
@ -264,7 +264,7 @@ export default function({ getService }: FtrProviderContext) {
it('should redirect to `logged_out` page after successful logout', async () => {
// First authenticate user to retrieve session cookie.
const response = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.ca(CA_CERT)
.pfx(FIRST_CLIENT_CERT)
.expect(200);
@ -277,7 +277,7 @@ export default function({ getService }: FtrProviderContext) {
// And then log user out.
const logoutResponse = await supertest
.get('/api/security/v1/logout')
.get('/api/security/logout')
.ca(CA_CERT)
.pfx(FIRST_CLIENT_CERT)
.set('Cookie', sessionCookie.cookieString())
@ -292,7 +292,7 @@ export default function({ getService }: FtrProviderContext) {
it('should redirect to home page if session cookie is not provided', async () => {
const logoutResponse = await supertest
.get('/api/security/v1/logout')
.get('/api/security/logout')
.ca(CA_CERT)
.pfx(FIRST_CLIENT_CERT)
.expect(302);
@ -307,7 +307,7 @@ export default function({ getService }: FtrProviderContext) {
beforeEach(async () => {
const response = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.ca(CA_CERT)
.pfx(FIRST_CLIENT_CERT)
.expect(200);
@ -329,7 +329,7 @@ export default function({ getService }: FtrProviderContext) {
// This api call should succeed and automatically refresh token. Returned cookie will contain
// the new access token.
const apiResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.ca(CA_CERT)
.pfx(FIRST_CLIENT_CERT)
.set('kbn-xsrf', 'xxx')

View file

@ -42,7 +42,7 @@ export default function({ getService }: FtrProviderContext) {
expect(sessionCookie.httpOnly).to.be(true);
const apiResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
@ -64,7 +64,7 @@ export default function({ getService }: FtrProviderContext) {
describe('SAML authentication', () => {
it('should reject API requests if client is not authenticated', async () => {
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.expect(401);
});
@ -72,7 +72,7 @@ export default function({ getService }: FtrProviderContext) {
it('does not prevent basic login', async () => {
const [username, password] = config.get('servers.elasticsearch.auth').split(':');
const response = await supertest
.post('/api/security/v1/login')
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({ username, password })
.expect(204);
@ -81,7 +81,7 @@ export default function({ getService }: FtrProviderContext) {
expect(cookies).to.have.length(1);
const { body: user } = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', request.cookie(cookies[0])!.cookieString())
.expect(200);
@ -192,7 +192,7 @@ export default function({ getService }: FtrProviderContext) {
const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!;
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(401);
@ -300,7 +300,7 @@ export default function({ getService }: FtrProviderContext) {
it('should extend cookie on every successful non-system API call', async () => {
const apiResponseOne = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
@ -312,7 +312,7 @@ export default function({ getService }: FtrProviderContext) {
expect(sessionCookieOne.value).to.not.equal(sessionCookie.value);
const apiResponseTwo = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
@ -326,7 +326,7 @@ export default function({ getService }: FtrProviderContext) {
it('should not extend cookie for system API calls', async () => {
const systemAPIResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('kbn-system-api', 'true')
.set('Cookie', sessionCookie.cookieString())
@ -337,7 +337,7 @@ export default function({ getService }: FtrProviderContext) {
it('should fail and preserve session cookie if unsupported authentication schema is used', async () => {
const apiResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Authorization', 'Basic AbCdEf')
.set('Cookie', sessionCookie.cookieString())
@ -383,7 +383,7 @@ export default function({ getService }: FtrProviderContext) {
it('should redirect to IdP with SAML request to complete logout', async () => {
const logoutResponse = await supertest
.get('/api/security/v1/logout')
.get('/api/security/logout')
.set('Cookie', sessionCookie.cookieString())
.expect(302);
@ -404,7 +404,7 @@ export default function({ getService }: FtrProviderContext) {
// Tokens that were stored in the previous cookie should be invalidated as well and old
// session cookie should not allow API access.
const apiResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(400);
@ -417,7 +417,7 @@ export default function({ getService }: FtrProviderContext) {
});
it('should redirect to home page if session cookie is not provided', async () => {
const logoutResponse = await supertest.get('/api/security/v1/logout').expect(302);
const logoutResponse = await supertest.get('/api/security/logout').expect(302);
expect(logoutResponse.headers['set-cookie']).to.be(undefined);
expect(logoutResponse.headers.location).to.be('/');
@ -425,7 +425,7 @@ export default function({ getService }: FtrProviderContext) {
it('should reject AJAX requests', async () => {
const ajaxResponse = await supertest
.get('/api/security/v1/logout')
.get('/api/security/logout')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(400);
@ -441,7 +441,7 @@ export default function({ getService }: FtrProviderContext) {
it('should invalidate access token on IdP initiated logout', async () => {
const logoutRequest = await createLogoutRequest({ sessionIndex: idpSessionIndex });
const logoutResponse = await supertest
.get(`/api/security/v1/logout?${querystring.stringify(logoutRequest)}`)
.get(`/api/security/logout?${querystring.stringify(logoutRequest)}`)
.set('Cookie', sessionCookie.cookieString())
.expect(302);
@ -462,7 +462,7 @@ export default function({ getService }: FtrProviderContext) {
// Tokens that were stored in the previous cookie should be invalidated as well and old session
// cookie should not allow API access.
const apiResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(400);
@ -477,7 +477,7 @@ export default function({ getService }: FtrProviderContext) {
it('should invalidate access token on IdP initiated logout even if there is no Kibana session', async () => {
const logoutRequest = await createLogoutRequest({ sessionIndex: idpSessionIndex });
const logoutResponse = await supertest
.get(`/api/security/v1/logout?${querystring.stringify(logoutRequest)}`)
.get(`/api/security/logout?${querystring.stringify(logoutRequest)}`)
.expect(302);
expect(logoutResponse.headers['set-cookie']).to.be(undefined);
@ -490,7 +490,7 @@ export default function({ getService }: FtrProviderContext) {
// IdP session id (encoded in SAML LogoutRequest) even if Kibana doesn't provide them and session
// cookie with these tokens should not allow API access.
const apiResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(400);
@ -548,7 +548,7 @@ export default function({ getService }: FtrProviderContext) {
// This api call should succeed and automatically refresh token. Returned cookie will contain
// the new access and refresh token pair.
const firstResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
@ -562,7 +562,7 @@ export default function({ getService }: FtrProviderContext) {
// Request with old cookie should reuse the same refresh token if within 60 seconds.
// Returned cookie will contain the same new access and refresh token pairs as the first request
const secondResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
@ -577,14 +577,14 @@ export default function({ getService }: FtrProviderContext) {
// The first new cookie with fresh pair of access and refresh tokens should work.
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', firstNewCookie.cookieString())
.expect(200);
// The second new cookie with fresh pair of access and refresh tokens should work.
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', secondNewCookie.cookieString())
.expect(200);
@ -701,7 +701,7 @@ export default function({ getService }: FtrProviderContext) {
// Tokens from old cookie are invalidated.
const rejectedResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', existingSessionCookie.cookieString())
.expect(400);
@ -712,7 +712,7 @@ export default function({ getService }: FtrProviderContext) {
// Only tokens from new session are valid.
const acceptedResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', newSessionCookie.cookieString())
.expect(200);
@ -737,7 +737,7 @@ export default function({ getService }: FtrProviderContext) {
// Tokens from old cookie are invalidated.
const rejectedResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', existingSessionCookie.cookieString())
.expect(400);
@ -748,7 +748,7 @@ export default function({ getService }: FtrProviderContext) {
// Only tokens from new session are valid.
const acceptedResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', newSessionCookie.cookieString())
.expect(200);

View file

@ -8,7 +8,7 @@ import { format as formatUrl } from 'url';
import * as legacyElasticsearch from 'elasticsearch';
import shieldPlugin from '../../../../legacy/server/lib/esjs_shield_plugin';
import { elasticsearchClientPlugin } from '../../../../plugins/security/server/elasticsearch_client_plugin';
export function LegacyEsProvider({ getService }) {
const config = getService('config');
@ -16,6 +16,6 @@ export function LegacyEsProvider({ getService }) {
return new legacyElasticsearch.Client({
host: formatUrl(config.get('servers.elasticsearch')),
requestTimeout: config.get('timeouts.esRequestTimeout'),
plugins: [shieldPlugin],
plugins: [elasticsearchClientPlugin],
});
}

View file

@ -8,7 +8,7 @@ import { format as formatUrl } from 'url';
import * as legacyElasticsearch from 'elasticsearch';
import shieldPlugin from '../../../../legacy/server/lib/esjs_shield_plugin';
import { elasticsearchClientPlugin } from '../../../../plugins/security/server/elasticsearch_client_plugin';
export function LegacyEsProvider({ getService }) {
const config = getService('config');
@ -16,6 +16,6 @@ export function LegacyEsProvider({ getService }) {
return new legacyElasticsearch.Client({
host: formatUrl(config.get('servers.elasticsearch')),
requestTimeout: config.get('timeouts.esRequestTimeout'),
plugins: [shieldPlugin]
plugins: [elasticsearchClientPlugin]
});
}

View file

@ -25,7 +25,7 @@ export default function ({ getService }) {
const token = await createToken();
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'true')
.set('authorization', `Bearer ${token}`)
.expect(200);
@ -36,14 +36,14 @@ export default function ({ getService }) {
// try it once
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'true')
.set('authorization', `Bearer ${token}`)
.expect(200);
// try it again to verity it isn't invalidated after a single request
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'true')
.set('authorization', `Bearer ${token}`)
.expect(200);
@ -51,7 +51,7 @@ export default function ({ getService }) {
it('rejects invalid access token via authorization Bearer header', async () => {
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'true')
.set('authorization', 'Bearer notreal')
.expect(401);
@ -67,7 +67,7 @@ export default function ({ getService }) {
await new Promise(resolve => setTimeout(() => resolve(), 20000));
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'true')
.set('authorization', `Bearer ${token}`)
.expect(401);

View file

@ -17,7 +17,7 @@ export default function ({ getService }) {
describe('login', () => {
it('accepts valid login credentials as 204 status', async () => {
await supertest
.post('/api/security/v1/login')
.post('/internal/security/login')
.set('kbn-xsrf', 'true')
.send({ username: 'elastic', password: 'changeme' })
.expect(204);
@ -25,7 +25,7 @@ export default function ({ getService }) {
it('sets HttpOnly cookie with valid login', async () => {
const response = await supertest
.post('/api/security/v1/login')
.post('/internal/security/login')
.set('kbn-xsrf', 'true')
.send({ username: 'elastic', password: 'changeme' })
.expect(204);
@ -42,7 +42,7 @@ export default function ({ getService }) {
it('rejects without kbn-xsrf header as 400 status even if credentials are valid', async () => {
const response = await supertest
.post('/api/security/v1/login')
.post('/internal/security/login')
.send({ username: 'elastic', password: 'changeme' })
.expect(400);
@ -53,7 +53,7 @@ export default function ({ getService }) {
it('rejects without credentials as 400 status', async () => {
const response = await supertest
.post('/api/security/v1/login')
.post('/internal/security/login')
.set('kbn-xsrf', 'true')
.expect(400);
@ -64,7 +64,7 @@ export default function ({ getService }) {
it('rejects without password as 400 status', async () => {
const response = await supertest
.post('/api/security/v1/login')
.post('/internal/security/login')
.set('kbn-xsrf', 'true')
.send({ username: 'elastic' })
.expect(400);
@ -76,7 +76,7 @@ export default function ({ getService }) {
it('rejects without username as 400 status', async () => {
const response = await supertest
.post('/api/security/v1/login')
.post('/internal/security/login')
.set('kbn-xsrf', 'true')
.send({ password: 'changme' })
.expect(400);
@ -88,7 +88,7 @@ export default function ({ getService }) {
it('rejects invalid credentials as 401 status', async () => {
const response = await supertest
.post('/api/security/v1/login')
.post('/internal/security/login')
.set('kbn-xsrf', 'true')
.send({ username: 'elastic', password: 'notvalidpassword' })
.expect(401);

View file

@ -16,7 +16,7 @@ export default function ({ getService }) {
async function createSessionCookie() {
const response = await supertest
.post('/api/security/v1/login')
.post('/internal/security/login')
.set('kbn-xsrf', 'true')
.send({ username: 'elastic', password: 'changeme' });
@ -33,7 +33,7 @@ export default function ({ getService }) {
const cookie = await createSessionCookie();
await supertest
.get('/api/security/v1/logout')
.get('/api/security/logout')
.set('cookie', cookie.cookieString())
.expect(302)
.expect('location', '/login?msg=LOGGED_OUT');
@ -43,7 +43,7 @@ export default function ({ getService }) {
const cookie = await createSessionCookie();
const response = await supertest
.get('/api/security/v1/logout')
.get('/api/security/logout')
.set('cookie', cookie.cookieString());
const newCookie = extractSessionCookie(response);
@ -60,12 +60,12 @@ export default function ({ getService }) {
// destroy it
await supertest
.get('/api/security/v1/logout')
.get('/api/security/logout')
.set('cookie', cookie.cookieString());
// verify that the cookie no longer works
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'true')
.set('cookie', cookie.cookieString())
.expect(400);

View file

@ -19,7 +19,7 @@ export default function ({ getService }) {
async function createSessionCookie() {
const response = await supertest
.post('/api/security/v1/login')
.post('/internal/security/login')
.set('kbn-xsrf', 'true')
.send({ username: 'elastic', password: 'changeme' });
@ -36,7 +36,7 @@ export default function ({ getService }) {
const cookie = await createSessionCookie();
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'true')
.set('cookie', cookie.cookieString())
.expect(200);
@ -47,14 +47,14 @@ export default function ({ getService }) {
// try it once
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'true')
.set('cookie', cookie.cookieString())
.expect(200);
// try it again to verity it isn't invalidated after a single request
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'true')
.set('cookie', cookie.cookieString())
.expect(200);
@ -85,7 +85,7 @@ export default function ({ getService }) {
// This api call should succeed and automatically refresh token. Returned cookie will contain
// the new access and refresh token pair.
const firstResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'true')
.set('cookie', originalCookie.cookieString())
.expect(200);
@ -96,7 +96,7 @@ export default function ({ getService }) {
// Request with old cookie should return another valid cookie we can use to authenticate requests
// if it happens within 60 seconds of the refresh token being used
const secondResponse = await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'true')
.set('Cookie', originalCookie.cookieString())
.expect(200);
@ -110,14 +110,14 @@ export default function ({ getService }) {
// The first new cookie should authenticate a subsequent request
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'true')
.set('Cookie', firstNewCookie.cookieString())
.expect(200);
// The second new cookie should authenticate a subsequent request
await supertest
.get('/api/security/v1/me')
.get('/internal/security/me')
.set('kbn-xsrf', 'true')
.set('Cookie', secondNewCookie.cookieString())
.expect(200);