0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-17 22:31:28 -05:00

refactor(ui): add session guard to ui page (#803)

* refactor(ui): add session guard to ui page

add session guard to ui page

* test(core): fix ut

fix ut

* fix(core):  fix typo

fix typo
This commit is contained in:
simeng-li 2022-05-16 13:43:23 +08:00 committed by GitHub
parent 4020096319
commit 78d3bb6045
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 142 additions and 9 deletions

View file

@ -7,6 +7,7 @@ import koaLogger from 'koa-logger';
import mount from 'koa-mount';
import envSet, { MountedApps } from '@/env-set';
import koaClientSessionGuard from '@/middleware/koa-client-session-guard';
import koaConnectorErrorHandler from '@/middleware/koa-connector-error-handle';
import koaErrorHandler from '@/middleware/koa-error-handler';
import koaI18next from '@/middleware/koa-i18next';
@ -37,6 +38,8 @@ export default async function initApp(app: Koa): Promise<void> {
app.use(
mount('/' + MountedApps.Console, koaSpaProxy(MountedApps.Console, 5002, MountedApps.Console))
);
app.use(koaClientSessionGuard(provider));
app.use(koaSpaProxy());
const { httpsCert, httpsKey, port } = envSet.values;

View file

@ -0,0 +1,77 @@
import { Provider } from 'oidc-provider';
import { MountedApps } from '@/env-set';
import { createContextWithRouteParameters } from '@/utils/test-utils';
import koaClientSessionGuard, { sessionNotFoundPath } from './koa-client-session-guard';
jest.mock('fs/promises', () => ({
...jest.requireActual('fs/promises'),
readdir: jest.fn().mockResolvedValue(['index.js']),
}));
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails: jest.fn(),
})),
}));
describe('koaClientSessionGuard', () => {
const envBackup = process.env;
beforeEach(() => {
process.env = { ...envBackup };
});
afterEach(() => {
jest.clearAllMocks();
jest.resetModules();
});
const next = jest.fn();
for (const app of Object.values(MountedApps)) {
// eslint-disable-next-line @typescript-eslint/no-loop-func
it(`${app} path should not redirect`, async () => {
const provider = new Provider('');
const ctx = createContextWithRouteParameters({
url: `/${app}/foo`,
});
await koaClientSessionGuard(provider)(ctx, next);
expect(ctx.redirect).not.toBeCalled();
});
}
it('should not redirect if session found', async () => {
const provider = new Provider('');
const ctx = createContextWithRouteParameters({
url: `/sign-in`,
});
await koaClientSessionGuard(provider)(ctx, next);
expect(ctx.redirect).not.toBeCalled();
});
it('should not redirect if path is sessionNotFoundPath', async () => {
const provider = new Provider('');
(provider.interactionDetails as jest.Mock).mockRejectedValue(new Error('session not found'));
const ctx = createContextWithRouteParameters({
url: `${sessionNotFoundPath}`,
});
await koaClientSessionGuard(provider)(ctx, next);
expect(ctx.redirect).not.toBeCalled();
});
it('should redirect if session not found', async () => {
const provider = new Provider('');
(provider.interactionDetails as jest.Mock).mockRejectedValue(new Error('session not found'));
const ctx = createContextWithRouteParameters({
url: '/sign-in',
});
await koaClientSessionGuard(provider)(ctx, next);
expect(ctx.redirect).toBeCalled();
});
});

View file

@ -0,0 +1,44 @@
import fs from 'fs/promises';
import path from 'path';
import { MiddlewareType } from 'koa';
import { IRouterParamContext } from 'koa-router';
import { Provider } from 'oidc-provider';
import { MountedApps } from '@/env-set';
import { fromRoot } from '@/env-set/parameters';
export const sessionNotFoundPath = '/unknown-session';
export default function koaSpaSessionGuard<
StateT,
ContextT extends IRouterParamContext,
ResponseBodyT
>(provider: Provider): MiddlewareType<StateT, ContextT, ResponseBodyT> {
return async (ctx, next) => {
const requestPath = ctx.request.path;
const packagesPath = fromRoot ? 'packages/' : '..';
const distPath = path.join(packagesPath, 'ui', 'dist');
// Guard client routes only
if (Object.values(MountedApps).some((app) => requestPath.startsWith(`/${app}`))) {
return next();
}
try {
// Find session
await provider.interactionDetails(ctx.req, ctx.res);
} catch {
const spaDistFiles = await fs.readdir(distPath);
if (
!spaDistFiles.some((file) => requestPath.startsWith('/' + file)) &&
!ctx.request.path.endsWith(sessionNotFoundPath)
) {
ctx.redirect(sessionNotFoundPath);
}
return next();
}
};
}

View file

@ -83,8 +83,9 @@ const translation = {
passwords_do_not_match: 'Passwords do not match.',
agree_terms_required: 'You must agree to the Terms of Use before continuing.',
invalid_passcode: 'The passcode is invalid.',
request: 'Request Error:{{message}}',
unknown: 'Request Error, please try again later.',
request: 'Request error {{message}}',
unknown: 'Unknown error, please try again later.',
invalid_session: 'Session not found. Please go back and sign in again.',
},
},
admin_console: {

View file

@ -83,8 +83,9 @@ const translation = {
passwords_do_not_match: '密码不匹配。',
agree_terms_required: '你需要同意使用条款以继续。',
invalid_passcode: '无效的验证码。',
request: '请求异常:{{ message }}',
unknown: '请求异常,请稍后重试。',
request: '请求错误:{{ message }}',
unknown: '未知错误,请稍后重试。',
invalid_session: '未找到有效的会话,请重新登录。',
},
},
admin_console: {

View file

@ -54,6 +54,10 @@ const App = () => {
<Route path="/callback/:connector" element={<Callback />} />
<Route path="/social-register/:connector" element={<SocialRegister />} />
<Route path="/:type/:method/passcode-validation" element={<Passcode />} />
<Route
path="/unknown-session"
element={<ErrorPage message="error.invalid_session" />}
/>
<Route path="*" element={<ErrorPage />} />
</Routes>
</BrowserRouter>

View file

@ -8,10 +8,10 @@ describe('ErrorPage Page', () => {
it('render properly', () => {
const { queryByText } = render(
<MemoryRouter>
<ErrorPage title="description.not_found" message="error message" />
<ErrorPage title="description.not_found" message="error.invalid_email" />
</MemoryRouter>
);
expect(queryByText('description.not_found')).not.toBeNull();
expect(queryByText('error message')).not.toBeNull();
expect(queryByText('error.invalid_email')).not.toBeNull();
});
});

View file

@ -10,20 +10,23 @@ import * as styles from './index.module.scss';
type Props = {
title?: TFuncKey<'translation', 'main_flow'>;
message?: string;
message?: TFuncKey<'translation', 'main_flow'>;
rawMessage?: string;
};
const ErrorPage = ({ title = 'description.not_found', message }: Props) => {
const ErrorPage = ({ title = 'description.not_found', message, rawMessage }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
const navigate = useNavigate();
const errorMessage = rawMessage || (message && t(message));
return (
<div className={styles.wrapper}>
<NavBar />
<div className={styles.container}>
<ErrorIcon />
<div className={styles.title}>{t(title)}</div>
{message && <div className={styles.message}>{message}</div>}
{errorMessage && <div className={styles.message}>{errorMessage}</div>}
</div>
<Button
className={styles.backButton}