mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
Migrate the rest of the API endpoints to the New Platform plugin (#50695)
This commit is contained in:
parent
a91e53f18f
commit
2ec82d3dd9
92 changed files with 2885 additions and 2713 deletions
|
@ -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]
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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[]> {
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -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 || {};
|
||||
|
|
|
@ -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}`);
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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)
|
||||
});
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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(),
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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'
|
||||
}]
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -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(),
|
||||
})
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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'),
|
||||
}]
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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]
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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]
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
};
|
||||
}));
|
|
@ -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] });
|
||||
});
|
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
576
x-pack/plugins/security/server/elasticsearch_client_plugin.ts
Normal file
576
x-pack/plugins/security/server/elasticsearch_client_plugin.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
160
x-pack/plugins/security/server/routes/api_keys/get.test.ts
Normal file
160
x-pack/plugins/security/server/routes/api_keys/get.test.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
43
x-pack/plugins/security/server/routes/api_keys/get.ts
Normal file
43
x-pack/plugins/security/server/routes/api_keys/get.ts
Normal 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));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
16
x-pack/plugins/security/server/routes/api_keys/index.ts
Normal file
16
x-pack/plugins/security/server/routes/api_keys/index.ts
Normal 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);
|
||||
}
|
|
@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
69
x-pack/plugins/security/server/routes/api_keys/invalidate.ts
Normal file
69
x-pack/plugins/security/server/routes/api_keys/invalidate.ts
Normal 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));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
49
x-pack/plugins/security/server/routes/api_keys/privileges.ts
Normal file
49
x-pack/plugins/security/server/routes/api_keys/privileges.ts
Normal 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));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
274
x-pack/plugins/security/server/routes/authentication/oidc.ts
Normal file
274
x-pack/plugins/security/server/routes/authentication/oidc.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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' } },
|
||||
});
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
|
|
@ -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' } },
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
47
x-pack/plugins/security/server/routes/indices/get_fields.ts
Normal file
47
x-pack/plugins/security/server/routes/indices/get_fields.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
32
x-pack/plugins/security/server/routes/users/delete.ts
Normal file
32
x-pack/plugins/security/server/routes/users/delete.ts
Normal 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));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
37
x-pack/plugins/security/server/routes/users/get.ts
Normal file
37
x-pack/plugins/security/server/routes/users/get.ts
Normal 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));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
27
x-pack/plugins/security/server/routes/users/get_all.ts
Normal file
27
x-pack/plugins/security/server/routes/users/get_all.ts
Normal 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));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
20
x-pack/plugins/security/server/routes/users/index.ts
Normal file
20
x-pack/plugins/security/server/routes/users/index.ts
Normal 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);
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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],
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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',
|
||||
]),
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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],
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue