mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Auth: Allow soft token revocation (#31601)
* Add revoked_at field to user auth token to allow soft revokes * Allow soft token revocations * Update token revocations and tests * Return error info on revokedTokenErr * Override session cookie only when no revokedErr nor API request * Display modal on revoked token error * Feedback: Refactor TokenRevokedModal to FC * Add GetUserRevokedTokens into UserTokenService * Backendsrv: adds tests and refactors soft token path * Apply feedback * Write redirect cookie on token revoked error * Update TokenRevokedModal style * Return meaningful error info * Some UI changes * Update backend_srv tests * Minor style fix on backend_srv tests * Replace deprecated method usage to publish events * Fix backend_srv tests * Apply suggestions from code review Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com> Co-authored-by: Hugo Häggmark <hugo.haggmark@gmail.com> * Apply suggestions from code review * Apply suggestions from code review Co-authored-by: Hugo Häggmark <hugo.haggmark@gmail.com> * Minor style fix after PR suggestion commit * Apply suggestions from code review Co-authored-by: Ursula Kallio <73951760+osg-grafana@users.noreply.github.com> * Prettier fixes Co-authored-by: Hugo Häggmark <hugo.haggmark@gmail.com> Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com> Co-authored-by: Ursula Kallio <73951760+osg-grafana@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
a1c7e0630d
commit
610999cfa2
@@ -1,4 +1,4 @@
|
||||
import { from, merge, MonoTypeOperatorFunction, Observable, Subject, Subscription, throwError } from 'rxjs';
|
||||
import { from, merge, MonoTypeOperatorFunction, Observable, of, Subject, Subscription, throwError } from 'rxjs';
|
||||
import { catchError, filter, map, mergeMap, retryWhen, share, takeUntil, tap, throwIfEmpty } from 'rxjs/operators';
|
||||
import { fromFetch } from 'rxjs/fetch';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -16,6 +16,8 @@ import { isDataQuery, isLocalUrl } from '../utils/query';
|
||||
import { FetchQueue } from './FetchQueue';
|
||||
import { ResponseQueue } from './ResponseQueue';
|
||||
import { FetchQueueWorker } from './FetchQueueWorker';
|
||||
import { TokenRevokedModal } from 'app/features/users/TokenRevokedModal';
|
||||
import { ShowModalReactEvent } from '../../types/events';
|
||||
|
||||
const CANCEL_ALL_REQUESTS_REQUEST_ID = 'cancel_all_requests_request_id';
|
||||
|
||||
@@ -212,6 +214,19 @@ export class BackendSrv implements BackendService {
|
||||
const firstAttempt = i === 0 && options.retry === 0;
|
||||
|
||||
if (error.status === 401 && isLocalUrl(options.url) && firstAttempt && isSignedIn) {
|
||||
if (error.data?.error?.id === 'ERR_TOKEN_REVOKED') {
|
||||
this.dependencies.appEvents.publish(
|
||||
new ShowModalReactEvent({
|
||||
component: TokenRevokedModal,
|
||||
props: {
|
||||
maxConcurrentSessions: error.data?.error?.maxConcurrentSessions,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return of({});
|
||||
}
|
||||
|
||||
return from(this.loginPing()).pipe(
|
||||
catchError((err) => {
|
||||
if (err.status === 401) {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { BackendSrv } from '../services/backend_srv';
|
||||
import { ContextSrv, User } from '../services/context_srv';
|
||||
import { describe, expect } from '../../../test/lib/common';
|
||||
import { BackendSrvRequest, FetchError } from '@grafana/runtime';
|
||||
import { TokenRevokedModal } from '../../features/users/TokenRevokedModal';
|
||||
import { ShowModalReactEvent } from '../../types/events';
|
||||
|
||||
const getTestContext = (overides?: object) => {
|
||||
const defaults = {
|
||||
@@ -37,6 +39,7 @@ const getTestContext = (overides?: object) => {
|
||||
|
||||
const appEventsMock: EventBusExtended = ({
|
||||
emit: jest.fn(),
|
||||
publish: jest.fn(),
|
||||
} as any) as EventBusExtended;
|
||||
|
||||
const user: User = ({
|
||||
@@ -185,6 +188,36 @@ describe('backendSrv', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('when making an unsuccessful call because of soft token revocation', () => {
|
||||
it('then it should dispatch show Token Revoked modal event', async () => {
|
||||
const url = '/api/dashboard/';
|
||||
const { backendSrv, appEventsMock, logoutMock, expectRequestCallChain } = getTestContext({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'UnAuthorized',
|
||||
data: { message: 'Token revoked', error: { id: 'ERR_TOKEN_REVOKED', maxConcurrentSessions: 3 } },
|
||||
url,
|
||||
});
|
||||
|
||||
backendSrv.loginPing = jest.fn();
|
||||
|
||||
await backendSrv.request({ url, method: 'GET', retry: 0 }).catch(() => {
|
||||
expect(appEventsMock.publish).toHaveBeenCalledTimes(1);
|
||||
expect(appEventsMock.publish).toHaveBeenCalledWith(
|
||||
new ShowModalReactEvent({
|
||||
component: TokenRevokedModal,
|
||||
props: {
|
||||
maxConcurrentSessions: 3,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(backendSrv.loginPing).not.toHaveBeenCalled();
|
||||
expect(logoutMock).not.toHaveBeenCalled();
|
||||
expectRequestCallChain({ url, method: 'GET', retry: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when making an unsuccessful call and conditions for retry are favorable and retry throws', () => {
|
||||
it('then it throw error', async () => {
|
||||
jest.useFakeTimers();
|
||||
@@ -394,6 +427,36 @@ describe('backendSrv', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('when making an unsuccessful call because of soft token revocation', () => {
|
||||
it('then it should dispatch show Token Revoked modal event', async () => {
|
||||
const { backendSrv, logoutMock, appEventsMock, expectRequestCallChain } = getTestContext({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'UnAuthorized',
|
||||
data: { message: 'Token revoked', error: { id: 'ERR_TOKEN_REVOKED', maxConcurrentSessions: 3 } },
|
||||
});
|
||||
|
||||
backendSrv.loginPing = jest.fn();
|
||||
|
||||
const url = '/api/dashboard/';
|
||||
|
||||
await backendSrv.datasourceRequest({ url, method: 'GET', retry: 0 }).catch((error) => {
|
||||
expect(appEventsMock.publish).toHaveBeenCalledTimes(1);
|
||||
expect(appEventsMock.publish).toHaveBeenCalledWith(
|
||||
new ShowModalReactEvent({
|
||||
component: TokenRevokedModal,
|
||||
props: {
|
||||
maxConcurrentSessions: 3,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(backendSrv.loginPing).not.toHaveBeenCalled();
|
||||
expect(logoutMock).not.toHaveBeenCalled();
|
||||
expectRequestCallChain({ url, method: 'GET', retry: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when making an unsuccessful call and conditions for retry are favorable and retry throws', () => {
|
||||
it('then it throw error', async () => {
|
||||
const { backendSrv, logoutMock, expectRequestCallChain } = getTestContext({
|
||||
|
||||
66
public/app/features/users/TokenRevokedModal.tsx
Normal file
66
public/app/features/users/TokenRevokedModal.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { Button, InfoBox, Portal, stylesFactory, useTheme } from '@grafana/ui';
|
||||
import { getModalStyles } from '@grafana/ui/src/components/Modal/getModalStyles';
|
||||
import { css, cx } from 'emotion';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
|
||||
interface Props {
|
||||
maxConcurrentSessions?: number;
|
||||
}
|
||||
|
||||
export const TokenRevokedModal = (props: Props) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const styles = getStyles(theme);
|
||||
const modalStyles = getModalStyles(theme);
|
||||
|
||||
const showMaxConcurrentSessions = Boolean(props.maxConcurrentSessions);
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div className={modalStyles.modal}>
|
||||
<InfoBox title="You have been automatically signed out" severity="warning" className={styles.infobox}>
|
||||
<div className={styles.text}>
|
||||
<p>
|
||||
Your session token was automatically revoked because you have reached
|
||||
<strong>
|
||||
{` the maximum number of ${
|
||||
showMaxConcurrentSessions ? props.maxConcurrentSessions : ''
|
||||
} concurrent sessions `}
|
||||
</strong>
|
||||
for your account.
|
||||
</p>
|
||||
<p>
|
||||
<strong>To resume your session, sign in again.</strong>
|
||||
Contact your administrator or visit the license page to review your quota if you are repeatedly signed out
|
||||
automatically.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="md" variant="primary" onClick={redirectToLogin}>
|
||||
Sign in
|
||||
</Button>
|
||||
</InfoBox>
|
||||
</div>
|
||||
<div className={cx(modalStyles.modalBackdrop, styles.backdrop)} />
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
return {
|
||||
infobox: css`
|
||||
margin-bottom: 0;
|
||||
`,
|
||||
text: css`
|
||||
margin: ${theme.spacing.sm} 0 ${theme.spacing.md};
|
||||
`,
|
||||
backdrop: css`
|
||||
background-color: ${theme.colors.dashboardBg};
|
||||
opacity: 0.8;
|
||||
`,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user