-
Notifications
You must be signed in to change notification settings - Fork 8.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Expose session invalidation API. #92376
Changes from 1 commit
14b96cb
1f926b9
e793774
ac6499a
d0b9f5a
e200021
115a075
8987d23
26cf895
8f1caae
5941367
6e475cb
36248cc
62c446c
b6f714d
90f33c2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License | ||
* 2.0; you may not use this file except in compliance with the Elastic License | ||
* 2.0. | ||
*/ | ||
|
||
import type { ObjectType } from '@kbn/config-schema'; | ||
import type { PublicMethodsOf } from '@kbn/utility-types'; | ||
import type { SecurityRequestHandlerContext, SecurityRouter } from '../../types'; | ||
import { | ||
kibanaResponseFactory, | ||
RequestHandler, | ||
RouteConfig, | ||
} from '../../../../../../src/core/server'; | ||
import type { Session } from '../../session_management'; | ||
import { defineDeleteAllSessionsRoutes } from './delete_all'; | ||
|
||
import { httpServerMock } from '../../../../../../src/core/server/mocks'; | ||
import { routeDefinitionParamsMock } from '../index.mock'; | ||
import { sessionMock } from '../../session_management/session.mock'; | ||
|
||
describe('Delete all sessions routes', () => { | ||
let router: jest.Mocked<SecurityRouter>; | ||
let session: jest.Mocked<PublicMethodsOf<Session>>; | ||
beforeEach(() => { | ||
const routeParamsMock = routeDefinitionParamsMock.create(); | ||
router = routeParamsMock.router; | ||
|
||
session = sessionMock.create(); | ||
routeParamsMock.getSession.mockReturnValue(session); | ||
|
||
defineDeleteAllSessionsRoutes(routeParamsMock); | ||
}); | ||
|
||
describe('delete sessions', () => { | ||
let routeHandler: RequestHandler<any, any, any, SecurityRequestHandlerContext>; | ||
let routeConfig: RouteConfig<any, any, any, any>; | ||
beforeEach(() => { | ||
const [extendRouteConfig, extendRouteHandler] = router.delete.mock.calls.find( | ||
([{ path }]) => path === '/internal/security/session/_all' | ||
)!; | ||
|
||
routeConfig = extendRouteConfig; | ||
routeHandler = extendRouteHandler; | ||
}); | ||
|
||
it('correctly defines route.', () => { | ||
expect(routeConfig.options).toEqual({ tags: ['access:sessionManagement'] }); | ||
|
||
const querySchema = (routeConfig.validate as any).query as ObjectType; | ||
expect(() => | ||
querySchema.validate({ providerName: 'basic1' }) | ||
).toThrowErrorMatchingInlineSnapshot( | ||
`"[request query.providerType]: expected value of type [string] but got [undefined]"` | ||
); | ||
expect(() => querySchema.validate({ username: 'user' })).toThrowErrorMatchingInlineSnapshot( | ||
`"[request query.providerType]: expected value of type [string] but got [undefined]"` | ||
); | ||
expect(() => | ||
querySchema.validate({ providerName: 'basic1', username: 'user' }) | ||
).toThrowErrorMatchingInlineSnapshot( | ||
`"[request query.providerType]: expected value of type [string] but got [undefined]"` | ||
); | ||
|
||
expect(querySchema.validate(undefined)).toBeUndefined(); | ||
expect(querySchema.validate({})).toEqual({}); | ||
expect(querySchema.validate({ providerType: 'basic' })).toEqual({ providerType: 'basic' }); | ||
expect(querySchema.validate({ providerType: 'basic', providerName: 'basic1' })).toEqual({ | ||
providerType: 'basic', | ||
providerName: 'basic1', | ||
}); | ||
expect(querySchema.validate({ providerType: 'basic', username: 'user' })).toEqual({ | ||
providerType: 'basic', | ||
username: 'user', | ||
}); | ||
expect( | ||
querySchema.validate({ providerType: 'basic', providerName: 'basic1', username: 'user' }) | ||
).toEqual({ | ||
providerType: 'basic', | ||
providerName: 'basic1', | ||
username: 'user', | ||
}); | ||
}); | ||
|
||
it('uses query string to construct filter.', async () => { | ||
session.clearAll.mockResolvedValue(30); | ||
|
||
const mockRequest = httpServerMock.createKibanaRequest({ | ||
query: { providerType: 'basic', providerName: 'basic1', username: 'user' }, | ||
}); | ||
await expect( | ||
routeHandler( | ||
({} as unknown) as SecurityRequestHandlerContext, | ||
mockRequest, | ||
kibanaResponseFactory | ||
) | ||
).resolves.toEqual({ | ||
status: 200, | ||
options: { body: { total: 30 } }, | ||
payload: { total: 30 }, | ||
}); | ||
|
||
expect(session.clearAll).toHaveBeenCalledTimes(1); | ||
expect(session.clearAll).toHaveBeenCalledWith(mockRequest, { | ||
provider: { type: 'basic', name: 'basic1' }, | ||
username: 'user', | ||
}); | ||
}); | ||
|
||
it('does not specify filter if it is not specified in the query.', async () => { | ||
session.clearAll.mockResolvedValue(30); | ||
|
||
const mockRequest = httpServerMock.createKibanaRequest(); | ||
await expect( | ||
routeHandler( | ||
({} as unknown) as SecurityRequestHandlerContext, | ||
mockRequest, | ||
kibanaResponseFactory | ||
) | ||
).resolves.toEqual({ | ||
status: 200, | ||
options: { body: { total: 30 } }, | ||
payload: { total: 30 }, | ||
}); | ||
|
||
expect(session.clearAll).toHaveBeenCalledTimes(1); | ||
expect(session.clearAll).toHaveBeenCalledWith(mockRequest, undefined); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License | ||
* 2.0; you may not use this file except in compliance with the Elastic License | ||
* 2.0. | ||
*/ | ||
|
||
import typeDetect from 'type-detect'; | ||
import { schema } from '@kbn/config-schema'; | ||
import { RouteDefinitionParams } from '..'; | ||
|
||
/** | ||
* Defines routes required for the deleting of all sessions. | ||
*/ | ||
export function defineDeleteAllSessionsRoutes({ router, getSession }: RouteDefinitionParams) { | ||
router.delete( | ||
{ | ||
path: '/internal/security/session/_all', | ||
validate: { | ||
query: schema.maybe( | ||
schema.object( | ||
{ | ||
// `providerType` is actually required when any other options are specified, but such schema isn't | ||
// currently supported by the Core for `query` string parameters. To workaround that we mark this property | ||
// as optional and do custom validation instead: https://github.com/elastic/kibana/issues/92201 | ||
providerType: schema.maybe(schema.string()), | ||
providerName: schema.maybe(schema.string()), | ||
username: schema.maybe(schema.string()), | ||
}, | ||
{ | ||
validate(value) { | ||
if (typeof value?.providerType !== 'string' && Object.keys(value).length > 0) { | ||
return `[request query.providerType]: expected value of type [string] but got [${typeDetect( | ||
value?.providerType | ||
)}]`; | ||
} | ||
}, | ||
} | ||
) | ||
), | ||
}, | ||
options: { tags: ['access:sessionManagement'] }, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: do we have any precedent\ability to relax superuser-only requirement and, for example, give access to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We have the ability to grant access to any user with the "Global All" privilege (aka IMO this feels like something that only a superuser (or someone with I could see us eventually offering a way to "invalidate all of my sessions", but we aren't at a place to do that just yet, and adding this capability in would increase the scope of this PR quite a bit, I'd imagine. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Nothing concrete yet, I'm just not very happy that we require a "cluster superpower" to invalidate Kibana-specific sessions and There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yeah what you're saying makes sense. My fear is that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yeah, that's a good point, let's see how it goes with superuser then. |
||
}, | ||
async (_context, request, response) => { | ||
return response.ok({ | ||
body: { | ||
total: await getSession().clearAll( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similar to my comment about What do you think? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will become a bit confusing since we'd have a export type InvalidateSessionFilter =
| { type: 'all' }
| { type: 'by-sid'; sid: string }
| { type: 'by-query'; query: { provider: { type: string; name?: string }; usernameHash?: string } }; Or keep them separate, let me see if I can come up with non-confusing names for both. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I would be fine with either of these approaches! |
||
request, | ||
request.query?.providerType | ||
? { | ||
provider: { type: request.query.providerType, name: request.query.providerName }, | ||
username: request.query.username, | ||
} | ||
: undefined | ||
), | ||
}, | ||
}); | ||
} | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -76,6 +76,22 @@ export interface SessionValueContentToEncrypt { | |
state: unknown; | ||
} | ||
|
||
/** | ||
* Parameters provided for the `SessionIndex.clearAll` method that determine which session index | ||
* values should be cleared (removed from the index). | ||
*/ | ||
export interface ClearAllSessionFilter { | ||
/** | ||
* Descriptor of the authentication provider that created sessions that should be cleared. Provider | ||
* name is optional. | ||
*/ | ||
provider: { type: string; name?: string }; | ||
/** | ||
* Optional name of the user whose sessions should be cleared. | ||
*/ | ||
username?: string; | ||
} | ||
|
||
/** | ||
* The SIDs and AAD must be unpredictable to prevent guessing attacks, where an attacker is able to | ||
* guess or predict the ID of a valid session through statistical analysis techniques. That's why we | ||
|
@@ -375,6 +391,37 @@ export class Session { | |
sessionLogger.debug('Successfully invalidated session.'); | ||
} | ||
|
||
/** | ||
* Clears all existing session values. | ||
* @param request Request instance to clear session value for. | ||
* @param [filter] Filter that narrows down the list of the sessions that should be cleared. | ||
*/ | ||
async clearAll(request: KibanaRequest, filter?: ClearAllSessionFilter) { | ||
// For this case method we don't require request to have the associated session, but nevertheless | ||
// we still want to log the SID if session is available. | ||
const sessionCookieValue = await this.options.sessionCookie.get(request); | ||
const sessionLogger = this.getLoggerForSID(sessionCookieValue?.sid); | ||
sessionLogger.debug('Invalidating sessions.'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: it might be nice to include the filters used as part of this invalidate operation. I think even a simple There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep, we can do that. I was a bit concerned about logging a username if it's provided as afaik we never logged it anywhere before. It's probably too paranoid though. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a good point. Feel free to omit for now then |
||
|
||
const clearIndexFilter = filter?.username | ||
? { | ||
provider: filter.provider, | ||
usernameHash: createHash('sha3-256').update(filter.username).digest('hex'), | ||
} | ||
: filter; | ||
|
||
// There are two things to be aware of here: | ||
// 1. We don't clear the cookie for the current session as we cannot be sure that we removed the | ||
// session index value for this session, but it's not a big deal since it will be automatically | ||
// cleared as soon as it's reused anyway. | ||
// 2. We only remove session index values and don't invalidate any Elasticsearch tokens that | ||
// may have been stored there since we cannot decrypt the session content. To decrypt it we need | ||
// AAD string that is separately stored in the user browser cookie. | ||
const invalidatedSessionsCount = await this.options.sessionIndex.clearAll(clearIndexFilter); | ||
sessionLogger.debug(`Successfully invalidated ${invalidatedSessionsCount} session(s).`); | ||
return invalidatedSessionsCount; | ||
} | ||
|
||
private calculateExpiry( | ||
provider: AuthenticationProvider, | ||
currentLifespanExpiration?: number | null | ||
|
@@ -411,9 +458,9 @@ export class Session { | |
|
||
/** | ||
* Creates logger scoped to a specified session ID. | ||
* @param sid Session ID to create logger for. | ||
* @param [sid] Session ID to create logger for. | ||
*/ | ||
private getLoggerForSID(sid: string) { | ||
return this.options.logger.get(sid?.slice(-10)); | ||
private getLoggerForSID(sid?: string) { | ||
return this.options.logger.get(sid?.slice(-10) ?? 'x'.repeat(10)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. note: alternatively we can just use parent "context" if SID isn't provided. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it'd be nice to explicitly denote that this is a session-less request. What do you think about There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep, I like |
||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
note: I was also considering
POST
+body
+/internal/security/session/_invalidate
, but didn't find enough benefits comparing toDELETE
+query
. I know some ES APIs usebody
withDELETE
, but IIRC it's not widely recommended and some proxies/tools may not be happy about it (e.g. the REST tool I use 🙂 ).Let me know what approach you prefer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm learning towards
POST + body + /internal/security/session/_invalidate
. This name gives us more flexibility to expand its functionality over time. The_all
suffix we're using above confused me initially -- at first glance, I assumed it would always invalidate all sessions, but we also accept a query parameter to optionally invalidate a subset of sessions.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see you've noted in the PR description that this is an internal route for now. Is there a reason not to document this as a public endpoint? We intend for this to be used externally, and marking it as public won't preclude us from making a breaking change in a minor release if we need to. In other words, it could be an experimental/beta public API, much like our SO, Space, and Role APIs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, that's because I added filter at a later iteration 🙂 Agree it's confusing now.
That's a good point, I completely forgot about the experimental APIs! The "no guarantees yet" was my main motivator behind keeping this API as internal.