-
Notifications
You must be signed in to change notification settings - Fork 8.3k
/
authenticator.ts
322 lines (282 loc) · 11.9 KB
/
authenticator.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
/*
* 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 { Legacy } from 'kibana';
import { getClient } from '../../../../../server/lib/get_client_shield';
import { getErrorStatusCode } from '../errors';
import {
AuthenticationProviderOptions,
BaseAuthenticationProvider,
BasicAuthenticationProvider,
KerberosAuthenticationProvider,
RequestWithLoginAttempt,
SAMLAuthenticationProvider,
TokenAuthenticationProvider,
OIDCAuthenticationProvider,
} from './providers';
import { AuthenticationResult } from './authentication_result';
import { DeauthenticationResult } from './deauthentication_result';
import { Session } from './session';
import { LoginAttempt } from './login_attempt';
import { AuthenticationProviderSpecificOptions } from './providers/base';
import { Tokens } from './tokens';
interface ProviderSession {
provider: string;
state: unknown;
}
// Mapping between provider key defined in the config and authentication
// provider class that can handle specific authentication mechanism.
const providerMap = new Map<
string,
new (
options: AuthenticationProviderOptions,
providerSpecificOptions?: AuthenticationProviderSpecificOptions
) => BaseAuthenticationProvider
>([
['basic', BasicAuthenticationProvider],
['kerberos', KerberosAuthenticationProvider],
['saml', SAMLAuthenticationProvider],
['token', TokenAuthenticationProvider],
['oidc', OIDCAuthenticationProvider],
]);
function assertRequest(request: Legacy.Request) {
if (!request || typeof request !== 'object') {
throw new Error(`Request should be a valid object, was [${typeof request}].`);
}
}
/**
* Prepares options object that is shared among all authentication providers.
* @param server Server instance.
*/
function getProviderOptions(server: Legacy.Server) {
const config = server.config();
const client = getClient(server);
const log = server.log.bind(server);
return {
client,
log,
protocol: server.info.protocol,
hostname: config.get<string>('server.host'),
port: config.get<number>('server.port'),
basePath: config.get<string>('server.basePath'),
tokens: new Tokens({ client, log }),
...config.get('xpack.security.public'),
};
}
/**
* Prepares options object that is specific only to an authentication provider.
* @param server Server instance.
* @param providerType the type of the provider to get the options for.
*/
function getProviderSpecificOptions(
server: Legacy.Server,
providerType: string
): AuthenticationProviderSpecificOptions | undefined {
const config = server.config();
const providerOptionsConfigKey = `xpack.security.authc.${providerType}`;
if (config.has(providerOptionsConfigKey)) {
return config.get<AuthenticationProviderSpecificOptions>(providerOptionsConfigKey);
}
}
/**
* Instantiates authentication provider based on the provider key from config.
* @param providerType Provider type key.
* @param options Options to pass to provider's constructor.
* @param providerSpecificOptions Options that are specific to {@param providerType}.
*/
function instantiateProvider(
providerType: string,
options: AuthenticationProviderOptions,
providerSpecificOptions?: AuthenticationProviderSpecificOptions
) {
const ProviderClassName = providerMap.get(providerType);
if (!ProviderClassName) {
throw new Error(`Unsupported authentication provider name: ${providerType}.`);
}
return new ProviderClassName(options, providerSpecificOptions);
}
/**
* Authenticator is responsible for authentication of the request using chain of
* authentication providers. The chain is essentially a prioritized list of configured
* providers (typically of various types). The order of the list determines the order in
* which the providers will be consulted. During the authentication process, Authenticator
* will try to authenticate the request via one provider at a time. Once one of the
* providers successfully authenticates the request, the authentication is considered
* to be successful and the authenticated user will be associated with the request.
* If provider cannot authenticate the request, the next in line provider in the chain
* will be used. If all providers in the chain could not authenticate the request,
* the authentication is then considered to be unsuccessful and an authentication error
* will be returned.
*/
class Authenticator {
/**
* List of configured and instantiated authentication providers.
*/
private readonly providers: Map<string, BaseAuthenticationProvider>;
/**
* Instantiates Authenticator and bootstrap configured providers.
* @param server Server instance.
* @param session Session instance.
*/
constructor(private readonly server: Legacy.Server, private readonly session: Session) {
const config = this.server.config();
const authProviders = config.get<string[]>('xpack.security.authc.providers');
if (authProviders.length === 0) {
throw new Error(
'No authentication provider is configured. Verify `xpack.security.authc.providers` config value.'
);
}
const providerOptions = Object.freeze(getProviderOptions(server));
this.providers = new Map(
authProviders.map(providerType => {
const providerSpecificOptions = getProviderSpecificOptions(server, providerType);
return [
providerType,
instantiateProvider(providerType, providerOptions, providerSpecificOptions),
] as [string, BaseAuthenticationProvider];
})
);
}
/**
* Performs request authentication using configured chain of authentication providers.
* @param request Request instance.
*/
async authenticate(request: RequestWithLoginAttempt) {
assertRequest(request);
const isSystemApiRequest = this.server.plugins.kibana.systemApi.isSystemApiRequest(request);
const existingSession = await this.getSessionValue(request);
let authenticationResult;
for (const [providerType, provider] of this.providerIterator(existingSession)) {
// Check if current session has been set by this provider.
const ownsSession = existingSession && existingSession.provider === providerType;
authenticationResult = await provider.authenticate(
request,
ownsSession ? existingSession!.state : null
);
if (ownsSession || authenticationResult.shouldUpdateState()) {
// If authentication succeeds or requires redirect we should automatically extend existing user session,
// unless authentication has been triggered by a system API request. In case provider explicitly returns new
// state we should store it in the session regardless of whether it's a system API request or not.
const sessionCanBeUpdated =
(authenticationResult.succeeded() || authenticationResult.redirected()) &&
(authenticationResult.shouldUpdateState() || !isSystemApiRequest);
// If provider owned the session, but failed to authenticate anyway, that likely means that
// session is not valid and we should clear it. Also provider can specifically ask to clear
// session by setting it to `null` even if authentication attempt didn't fail.
if (
authenticationResult.shouldClearState() ||
(authenticationResult.failed() && getErrorStatusCode(authenticationResult.error) === 401)
) {
await this.session.clear(request);
} else if (sessionCanBeUpdated) {
await this.session.set(
request,
authenticationResult.shouldUpdateState()
? { state: authenticationResult.state, provider: providerType }
: existingSession
);
}
}
if (authenticationResult.failed()) {
return authenticationResult;
}
if (authenticationResult.succeeded()) {
return AuthenticationResult.succeeded(authenticationResult.user!);
} else if (authenticationResult.redirected()) {
return authenticationResult;
}
}
return authenticationResult;
}
/**
* Deauthenticates current request.
* @param request Request instance.
*/
async deauthenticate(request: RequestWithLoginAttempt) {
assertRequest(request);
const sessionValue = await this.getSessionValue(request);
if (sessionValue) {
await this.session.clear(request);
return this.providers.get(sessionValue.provider)!.deauthenticate(request, sessionValue.state);
}
// Normally when there is no active session in Kibana, `deauthenticate` method shouldn't do anything
// and user will eventually be redirected to the home page to log in. But if SAML is supported there
// is a special case when logout is initiated by the IdP or another SP, then IdP will request _every_
// SP associated with the current user session to do the logout. So if Kibana (without active session)
// receives such a request it shouldn't redirect user to the home page, but rather redirect back to IdP
// with correct logout response and only Elasticsearch knows how to do that.
if ((request.query as Record<string, string>).SAMLRequest && this.providers.has('saml')) {
return this.providers.get('saml')!.deauthenticate(request);
}
return DeauthenticationResult.notHandled();
}
/**
* Returns provider iterator where providers are sorted in the order of priority (based on the session ownership).
* @param sessionValue Current session value.
*/
*providerIterator(
sessionValue: ProviderSession | null
): IterableIterator<[string, BaseAuthenticationProvider]> {
// If there is no session to predict which provider to use first, let's use the order
// providers are configured in. Otherwise return provider that owns session first, and only then the rest
// of providers.
if (!sessionValue) {
yield* this.providers;
} else {
yield [sessionValue.provider, this.providers.get(sessionValue.provider)!];
for (const [providerType, provider] of this.providers) {
if (providerType !== sessionValue.provider) {
yield [providerType, provider];
}
}
}
}
/**
* Extracts session value for the specified request. Under the hood it can
* clear session if it belongs to the provider that is not available.
* @param request Request to extract session value for.
*/
private async getSessionValue(request: Legacy.Request) {
let sessionValue = await this.session.get<ProviderSession>(request);
// If for some reason we have a session stored for the provider that is not available
// (e.g. when user was logged in with one provider, but then configuration has changed
// and that provider is no longer available), then we should clear session entirely.
if (sessionValue && !this.providers.has(sessionValue.provider)) {
await this.session.clear(request);
sessionValue = null;
}
return sessionValue;
}
}
export async function initAuthenticator(server: Legacy.Server) {
const session = await Session.create(server);
const authenticator = new Authenticator(server, session);
const loginAttempts = new WeakMap();
server.decorate('request', 'loginAttempt', function(this: Legacy.Request) {
const request = this;
if (!loginAttempts.has(request)) {
loginAttempts.set(request, new LoginAttempt());
}
return loginAttempts.get(request);
});
server.expose('authenticate', (request: RequestWithLoginAttempt) =>
authenticator.authenticate(request)
);
server.expose('deauthenticate', (request: RequestWithLoginAttempt) =>
authenticator.deauthenticate(request)
);
server.expose('isAuthenticated', async (request: Legacy.Request) => {
try {
await server.plugins.security!.getUser(request);
return true;
} catch (err) {
// Don't swallow server errors.
if (!err.isBoom || err.output.statusCode !== 401) {
throw err;
}
}
return false;
});
}